1use arrow::datatypes::SchemaRef;
80use arrow::record_batch::RecordBatch;
81use datafusion::common::{Result, Statistics};
82use datafusion::error::DataFusionError;
83use datafusion::execution::TaskContext;
84use datafusion::execution::disk_manager::RefCountedTempFile;
85use datafusion::execution::memory_pool::MemoryConsumer;
86use datafusion::physical_expr::PhysicalExpr;
87use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
88use datafusion::physical_plan::metrics::{ExecutionPlanMetricsSet, MetricsSet, SpillMetrics, Time};
89use datafusion::physical_plan::repartition::BatchPartitioner;
90use datafusion::physical_plan::spill::{SpillManager, get_record_batch_memory_size};
91use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
92use datafusion::physical_plan::{
93 DisplayAs, DisplayFormatType, Distribution, ExecutionPlan, PlanProperties,
94 SendableRecordBatchStream,
95};
96use futures::{StreamExt, TryStreamExt};
97use std::fmt;
98use std::sync::Arc;
99
100pub const GRACE_HASH_JOIN_ENV: &str = "KRISHIV_GRACE_HASH_JOIN";
107
108pub const GRACE_HASH_JOIN_BUCKETS_ENV: &str = "KRISHIV_GRACE_HASH_JOIN_BUCKETS";
110
111const DEFAULT_BUCKETS: usize = 32;
117
118const MIN_BUCKETS: usize = 2;
120const MAX_BUCKETS: usize = 256;
121
122#[must_use]
124pub fn enabled() -> bool {
125 std::env::var(GRACE_HASH_JOIN_ENV).is_ok_and(|v| {
126 let v = v.trim().to_ascii_lowercase();
127 v == "1" || v == "true" || v == "yes" || v == "on"
128 })
129}
130
131#[must_use]
137pub fn bucket_count(build_bytes: u64, budget: u64) -> usize {
138 if let Some(override_buckets) = std::env::var(GRACE_HASH_JOIN_BUCKETS_ENV)
139 .ok()
140 .and_then(|v| v.trim().parse::<usize>().ok())
141 .filter(|n| *n > 0)
142 {
143 return override_buckets.clamp(MIN_BUCKETS, MAX_BUCKETS);
144 }
145 let target = (budget / 2).max(1);
146 let needed = usize::try_from(build_bytes.div_ceil(target)).unwrap_or(MAX_BUCKETS);
147 needed.max(DEFAULT_BUCKETS).clamp(MIN_BUCKETS, MAX_BUCKETS)
148}
149
150#[derive(Debug)]
155pub struct GraceHashJoinExec {
156 template: Arc<HashJoinExec>,
162 buckets: usize,
164 build_budget: usize,
166 metrics: ExecutionPlanMetricsSet,
167}
168
169impl GraceHashJoinExec {
170 pub fn try_new(
188 template: Arc<HashJoinExec>,
189 buckets: usize,
190 build_budget: usize,
191 ) -> Result<Self> {
192 use datafusion::physical_plan::ExecutionPlanProperties;
193
194 let left = template.left().output_partitioning().partition_count();
195 let right = template.right().output_partitioning().partition_count();
196 if left != right {
197 return Err(DataFusionError::Plan(format!(
198 "grace hash join needs both sides partitioned alike, got {left} and {right} \
199 (mode {:?})",
200 template.partition_mode()
201 )));
202 }
203 Ok(Self {
204 template,
205 buckets: buckets.clamp(MIN_BUCKETS, MAX_BUCKETS),
206 build_budget: build_budget.max(1),
209 metrics: ExecutionPlanMetricsSet::new(),
210 })
211 }
212
213 #[must_use]
215 pub fn template(&self) -> &Arc<HashJoinExec> {
216 &self.template
217 }
218
219 #[must_use]
221 pub fn buckets(&self) -> usize {
222 self.buckets
223 }
224
225 #[must_use]
227 pub fn build_budget(&self) -> usize {
228 self.build_budget
229 }
230}
231
232impl DisplayAs for GraceHashJoinExec {
233 fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 match t {
235 DisplayFormatType::Default | DisplayFormatType::TreeRender => write!(
236 f,
237 "GraceHashJoinExec: join_type={:?}, buckets={}",
238 self.template.join_type(),
239 self.buckets
240 ),
241 DisplayFormatType::Verbose => write!(
242 f,
243 "GraceHashJoinExec: join_type={:?}, buckets={}, build_budget={}, on={:?}",
244 self.template.join_type(),
245 self.buckets,
246 self.build_budget,
247 self.template.on()
248 ),
249 }
250 }
251}
252
253impl ExecutionPlan for GraceHashJoinExec {
254 fn name(&self) -> &str {
255 "GraceHashJoinExec"
256 }
257
258 fn properties(&self) -> &Arc<PlanProperties> {
259 self.template.properties()
263 }
264
265 fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
266 vec![self.template.left(), self.template.right()]
267 }
268
269 fn required_input_distribution(&self) -> Vec<Distribution> {
270 self.template.required_input_distribution()
271 }
272
273 fn maintains_input_order(&self) -> Vec<bool> {
274 vec![false, false]
278 }
279
280 fn with_new_children(
281 self: Arc<Self>,
282 children: Vec<Arc<dyn ExecutionPlan>>,
283 ) -> Result<Arc<dyn ExecutionPlan>> {
284 let template = self
285 .template
286 .builder()
287 .reset_state()
288 .with_new_children(children)?
289 .build()?;
290 Ok(Arc::new(Self::try_new(
291 Arc::new(template),
292 self.buckets,
293 self.build_budget,
294 )?))
295 }
296
297 fn execute(
298 &self,
299 partition: usize,
300 context: Arc<TaskContext>,
301 ) -> Result<SendableRecordBatchStream> {
302 let schema = self.template.schema();
303 let template = Arc::clone(&self.template);
304 let metrics = self.metrics.clone();
305 let buckets = self.buckets;
306 let build_budget = self.build_budget;
307
308 let started = futures::stream::once(async move {
312 join(template, buckets, build_budget, partition, context, metrics).await
313 })
314 .try_flatten();
315
316 Ok(Box::pin(RecordBatchStreamAdapter::new(schema, started)))
317 }
318
319 fn metrics(&self) -> Option<MetricsSet> {
320 Some(self.metrics.clone_inner())
321 }
322
323 fn partition_statistics(&self, partition: Option<usize>) -> Result<Arc<Statistics>> {
324 self.template.partition_statistics(partition)
325 }
326}
327
328async fn join(
330 template: Arc<HashJoinExec>,
331 buckets: usize,
332 build_budget: usize,
333 partition: usize,
334 context: Arc<TaskContext>,
335 metrics: ExecutionPlanMetricsSet,
336) -> Result<SendableRecordBatchStream> {
337 let output_schema = template.schema();
338 let build_schema = template.left().schema();
339 let probe_schema = template.right().schema();
340
341 let mut build_stream = template.left().execute(partition, Arc::clone(&context))?;
342
343 let reservation = MemoryConsumer::new(format!("GraceHashJoinBuild[{partition}]"))
351 .with_can_spill(true)
352 .register(context.memory_pool());
353 let mut buffered: Vec<RecordBatch> = Vec::new();
354 let mut buffered_bytes: usize = 0;
355 let mut overflowed = false;
356
357 while let Some(batch) = build_stream.next().await {
358 let batch = batch?;
359 if batch.num_rows() == 0 {
360 continue;
361 }
362 let size = get_record_batch_memory_size(&batch);
363 if buffered_bytes.saturating_add(size) > build_budget || reservation.try_grow(size).is_err()
367 {
368 overflowed = true;
369 buffered.push(batch);
370 break;
371 }
372 buffered_bytes += size;
373 buffered.push(batch);
374 }
375
376 if !overflowed {
377 tracing::debug!(
378 partition,
379 buffered_bytes,
380 batches = buffered.len(),
381 "grace-hash-join: build side fits, joining in memory"
382 );
383 drop(reservation);
387 let probe = template.right().execute(partition, Arc::clone(&context))?;
388 let build_exec = memory_source(vec![buffered], build_schema)?;
389 let probe_exec = Arc::new(OnceStreamExec::new(probe_schema, probe));
390 return bucket_join(&template, build_exec, probe_exec)?.execute(0, context);
391 }
392
393 tracing::info!(
394 partition,
395 buffered_bytes,
396 build_budget,
397 buckets,
398 "grace-hash-join: build side exceeds the budget, partitioning to disk"
399 );
400
401 let build_keys: Vec<Arc<dyn PhysicalExpr>> =
403 template.on().iter().map(|(l, _)| Arc::clone(l)).collect();
404 let probe_keys: Vec<Arc<dyn PhysicalExpr>> =
405 template.on().iter().map(|(_, r)| Arc::clone(r)).collect();
406
407 let build_spills = SpillManager::new(
408 context.runtime_env(),
409 SpillMetrics::new(&metrics, partition),
410 Arc::clone(&build_schema),
411 );
412 let probe_spills = SpillManager::new(
413 context.runtime_env(),
414 SpillMetrics::new(&metrics, partition),
415 Arc::clone(&probe_schema),
416 );
417
418 let build_files = spill_by_bucket(
419 std::mem::take(&mut buffered),
420 build_stream,
421 build_keys,
422 buckets,
423 &build_spills,
424 "grace hash join build side",
425 )
426 .await?;
427 drop(reservation);
431
432 let probe_stream = template.right().execute(partition, Arc::clone(&context))?;
433 let probe_files = spill_by_bucket(
434 Vec::new(),
435 probe_stream,
436 probe_keys,
437 buckets,
438 &probe_spills,
439 "grace hash join probe side",
440 )
441 .await?;
442
443 let pairs: Vec<(usize, Option<RefCountedTempFile>, Option<RefCountedTempFile>)> = build_files
446 .into_iter()
447 .zip(probe_files)
448 .enumerate()
449 .map(|(bucket, (build, probe))| (bucket, build, probe))
450 .collect();
451
452 let joined = futures::stream::iter(pairs)
453 .map(Ok::<_, DataFusionError>)
454 .and_then(move |(bucket, build_file, probe_file)| {
455 let template = Arc::clone(&template);
456 let context = Arc::clone(&context);
457 let build_spills = build_spills.clone();
458 let probe_spills = probe_spills.clone();
459 let build_schema = Arc::clone(&build_schema);
460 let probe_schema = Arc::clone(&probe_schema);
461 async move {
462 join_bucket(
463 &template,
464 bucket,
465 build_file,
466 probe_file,
467 &build_spills,
468 &probe_spills,
469 build_schema,
470 probe_schema,
471 build_budget,
472 &context,
473 )
474 .await
475 }
476 })
477 .try_flatten();
478
479 Ok(Box::pin(RecordBatchStreamAdapter::new(
480 output_schema,
481 joined,
482 )))
483}
484
485#[expect(
487 clippy::too_many_arguments,
488 reason = "one bucket needs both sides' files, spill managers and schemas; \
489 bundling them into a struct would only move the list"
490)]
491async fn join_bucket(
492 template: &Arc<HashJoinExec>,
493 bucket: usize,
494 build_file: Option<RefCountedTempFile>,
495 probe_file: Option<RefCountedTempFile>,
496 build_spills: &SpillManager,
497 probe_spills: &SpillManager,
498 build_schema: SchemaRef,
499 probe_schema: SchemaRef,
500 build_budget: usize,
501 context: &Arc<TaskContext>,
502) -> Result<SendableRecordBatchStream> {
503 if build_file.is_none() && probe_file.is_none() {
506 return Ok(Box::pin(RecordBatchStreamAdapter::new(
507 template.schema(),
508 futures::stream::empty(),
509 )));
510 }
511
512 let build: Vec<RecordBatch> = match build_file {
513 Some(file) => {
514 build_spills
515 .read_spill_as_stream(file, None)?
516 .try_collect()
517 .await?
518 }
519 None => Vec::new(),
522 };
523 let bucket_bytes: usize = build.iter().map(get_record_batch_memory_size).sum();
524
525 let reservation = MemoryConsumer::new(format!("GraceHashJoinBucket[{bucket}]"))
540 .with_can_spill(false)
541 .register(context.memory_pool());
542 reservation.try_grow(bucket_bytes)?;
543
544 if bucket_bytes > build_budget {
545 tracing::warn!(
552 bucket,
553 bucket_bytes,
554 build_budget,
555 "grace-hash-join: bucket build side exceeds the per-task budget \
556 (key skew); joining it anyway, which may exhaust the pool"
557 );
558 } else {
559 tracing::debug!(bucket, bucket_bytes, "grace-hash-join: joining bucket");
560 }
561
562 let probe: SendableRecordBatchStream = match probe_file {
563 Some(file) => probe_spills.read_spill_as_stream(file, None)?,
564 None => Box::pin(RecordBatchStreamAdapter::new(
565 Arc::clone(&probe_schema),
566 futures::stream::empty(),
567 )),
568 };
569
570 let build_exec = memory_source(vec![build], build_schema)?;
571 let probe_exec = Arc::new(OnceStreamExec::new(probe_schema, probe));
572 let schema = template.schema();
573 let joined = bucket_join(template, build_exec, probe_exec)?.execute(0, Arc::clone(context))?;
574
575 let guarded = futures::stream::unfold(
579 (joined, reservation),
580 |(mut stream, reservation)| async move {
581 stream
582 .next()
583 .await
584 .map(|batch| (batch, (stream, reservation)))
585 },
586 );
587 Ok(Box::pin(RecordBatchStreamAdapter::new(schema, guarded)))
588}
589
590fn memory_source(
592 partitions: Vec<Vec<RecordBatch>>,
593 schema: SchemaRef,
594) -> Result<Arc<dyn ExecutionPlan>> {
595 let exec =
596 datafusion::datasource::memory::MemorySourceConfig::try_new_exec(&partitions, schema, None)?;
597 Ok(exec)
598}
599
600fn bucket_join(
608 template: &Arc<HashJoinExec>,
609 build: Arc<dyn ExecutionPlan>,
610 probe: Arc<dyn ExecutionPlan>,
611) -> Result<Arc<dyn ExecutionPlan>> {
612 template
613 .builder()
614 .reset_state()
617 .with_new_children(vec![build, probe])?
618 .with_partition_mode(PartitionMode::CollectLeft)
619 .recompute_properties()
620 .build_exec()
621}
622
623async fn spill_by_bucket(
628 prefix: Vec<RecordBatch>,
629 stream: SendableRecordBatchStream,
630 keys: Vec<Arc<dyn PhysicalExpr>>,
631 buckets: usize,
632 spills: &SpillManager,
633 request: &str,
634) -> Result<Vec<Option<RefCountedTempFile>>> {
635 let mut partitioner = BatchPartitioner::new_hash_partitioner(keys, buckets, Time::new())?;
636 let mut files = Vec::with_capacity(buckets);
639 for bucket in 0..buckets {
640 files.push(spills.create_in_progress_file(&format!("{request} bucket {bucket}"))?);
641 }
642
643 let mut all = futures::stream::iter(prefix.into_iter().map(Ok)).chain(stream);
647 while let Some(batch) = all.next().await {
648 let batch = batch?;
649 if batch.num_rows() == 0 {
650 continue;
651 }
652 partitioner.partition(batch, |bucket, part| {
653 if part.num_rows() == 0 {
654 return Ok(());
655 }
656 let file = files.get_mut(bucket).ok_or_else(|| {
660 DataFusionError::Internal(format!(
661 "grace hash join routed a batch to bucket {bucket} of {buckets}"
662 ))
663 })?;
664 file.append_batch(&part)?;
665 Ok(())
666 })?;
667 }
668
669 let mut finished = Vec::with_capacity(buckets);
670 for mut file in files {
671 finished.push(file.finish()?);
672 }
673 Ok(finished)
674}
675
676struct OnceStreamExec {
683 stream: std::sync::Mutex<Option<SendableRecordBatchStream>>,
684 properties: Arc<PlanProperties>,
685}
686
687impl fmt::Debug for OnceStreamExec {
688 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691 f.write_str("OnceStreamExec")
692 }
693}
694
695impl OnceStreamExec {
696 fn new(schema: SchemaRef, stream: SendableRecordBatchStream) -> Self {
697 use datafusion::physical_expr::EquivalenceProperties;
698 use datafusion::physical_plan::Partitioning;
699 use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
700
701 let properties = Arc::new(PlanProperties::new(
702 EquivalenceProperties::new(schema),
703 Partitioning::UnknownPartitioning(1),
704 EmissionType::Incremental,
705 Boundedness::Bounded,
706 ));
707 Self {
708 stream: std::sync::Mutex::new(Some(stream)),
709 properties,
710 }
711 }
712}
713
714impl DisplayAs for OnceStreamExec {
715 fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716 write!(f, "OnceStreamExec")
717 }
718}
719
720impl ExecutionPlan for OnceStreamExec {
721 fn name(&self) -> &str {
722 "OnceStreamExec"
723 }
724
725 fn properties(&self) -> &Arc<PlanProperties> {
726 &self.properties
727 }
728
729 fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
730 vec![]
731 }
732
733 fn with_new_children(
734 self: Arc<Self>,
735 _children: Vec<Arc<dyn ExecutionPlan>>,
736 ) -> Result<Arc<dyn ExecutionPlan>> {
737 Ok(self)
738 }
739
740 fn execute(
741 &self,
742 partition: usize,
743 _context: Arc<TaskContext>,
744 ) -> Result<SendableRecordBatchStream> {
745 if partition != 0 {
746 return Err(DataFusionError::Internal(format!(
747 "OnceStreamExec has one partition, asked for {partition}"
748 )));
749 }
750 self.stream
751 .lock()
752 .map_err(|_| DataFusionError::Internal("OnceStreamExec mutex poisoned".into()))?
753 .take()
754 .ok_or_else(|| DataFusionError::Internal("OnceStreamExec was already executed".into()))
755 }
756}
757
758#[cfg(test)]
759#[allow(clippy::unwrap_used, clippy::expect_used)]
760mod tests {
761 use super::*;
762 use arrow::array::{Int32Array, StringArray};
763 use arrow::datatypes::{DataType, Field, Schema};
764 use datafusion::common::{JoinType, NullEquality};
765 use datafusion::physical_expr::expressions::Column;
766 use datafusion::physical_plan::collect;
767 use datafusion::prelude::SessionContext;
768
769 fn build_schema() -> SchemaRef {
770 Arc::new(Schema::new(vec![
771 Field::new("k", DataType::Int32, true),
772 Field::new("v", DataType::Utf8, true),
773 ]))
774 }
775
776 fn probe_schema() -> SchemaRef {
777 Arc::new(Schema::new(vec![
778 Field::new("k", DataType::Int32, true),
779 Field::new("w", DataType::Int32, true),
780 ]))
781 }
782
783 fn build_batch(keys: Vec<Option<i32>>, vals: Vec<Option<&str>>) -> RecordBatch {
784 RecordBatch::try_new(
785 build_schema(),
786 vec![
787 Arc::new(Int32Array::from(keys)),
788 Arc::new(StringArray::from(vals)),
789 ],
790 )
791 .expect("build batch")
792 }
793
794 fn probe_batch(keys: Vec<Option<i32>>, ws: Vec<Option<i32>>) -> RecordBatch {
795 RecordBatch::try_new(
796 probe_schema(),
797 vec![
798 Arc::new(Int32Array::from(keys)),
799 Arc::new(Int32Array::from(ws)),
800 ],
801 )
802 .expect("probe batch")
803 }
804
805 fn build_rows() -> Vec<RecordBatch> {
807 vec![
808 build_batch(vec![Some(1), Some(1), Some(2)], vec![Some("a1"), Some("a2"), Some("b")]),
809 build_batch(vec![Some(3), Some(7)], vec![Some("c"), Some("g")]),
810 ]
811 }
812
813 fn probe_rows() -> Vec<RecordBatch> {
815 vec![
816 probe_batch(vec![Some(1), Some(2)], vec![Some(10), Some(20)]),
817 probe_batch(vec![Some(2), Some(4)], vec![Some(21), Some(40)]),
818 ]
819 }
820
821 fn source(schema: SchemaRef, batches: Vec<RecordBatch>) -> Arc<dyn ExecutionPlan> {
822 memory_source(vec![batches], schema).expect("memory source")
823 }
824
825 fn hash_join(
827 join_type: JoinType,
828 null_equality: NullEquality,
829 projection: Option<Vec<usize>>,
830 ) -> Arc<HashJoinExec> {
831 Arc::new(
832 HashJoinExec::try_new(
833 source(build_schema(), build_rows()),
834 source(probe_schema(), probe_rows()),
835 vec![(
836 Arc::new(Column::new("k", 0)),
837 Arc::new(Column::new("k", 0)),
838 )],
839 None,
840 &join_type,
841 projection,
842 PartitionMode::CollectLeft,
843 null_equality,
844 false,
845 )
846 .expect("hash join"),
847 )
848 }
849
850 async fn cells(plan: Arc<dyn ExecutionPlan>, ctx: &SessionContext) -> Vec<String> {
854 let batches = collect(plan, ctx.task_ctx()).await.expect("collect");
855 let mut rows: Vec<String> = batches
856 .iter()
857 .flat_map(|b| {
858 (0..b.num_rows()).map(move |r| {
859 (0..b.num_columns())
860 .map(|c| {
861 arrow::util::display::array_value_to_string(b.column(c), r)
862 .expect("cell")
863 })
864 .collect::<Vec<_>>()
865 .join("|")
866 })
867 })
868 .collect();
869 rows.sort();
870 rows
871 }
872
873 fn spill_files(plan: &GraceHashJoinExec) -> usize {
883 plan.metrics().and_then(|m| m.spill_count()).unwrap_or(0)
884 }
885
886 #[tokio::test]
894 async fn every_join_type_agrees_with_the_hash_join_it_replaces() {
895 let ctx = SessionContext::new();
896 for join_type in [
897 JoinType::Inner,
898 JoinType::Left,
899 JoinType::Right,
900 JoinType::Full,
901 JoinType::LeftSemi,
902 JoinType::LeftAnti,
903 JoinType::RightSemi,
904 JoinType::RightAnti,
905 ] {
906 let expected = cells(hash_join(join_type, NullEquality::NullEqualsNothing, None), &ctx).await;
907
908 let grace = Arc::new(
911 GraceHashJoinExec::try_new(
912 hash_join(join_type, NullEquality::NullEqualsNothing, None),
913 4,
914 1,
915 )
916 .expect("grace join"),
917 );
918 let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
919 assert!(
920 spill_files(&grace) > 0,
921 "{join_type:?} took the in-memory path, so this proved nothing"
922 );
923 assert_eq!(actual, expected, "{join_type:?} disagreed after partitioning");
924 }
925 }
926
927 #[tokio::test]
930 async fn the_inner_join_returns_the_rows_it_should() {
931 let ctx = SessionContext::new();
932 let grace = Arc::new(
933 GraceHashJoinExec::try_new(
934 hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
935 4,
936 1,
937 )
938 .expect("grace join"),
939 );
940 assert_eq!(
941 cells(grace, &ctx).await,
942 vec!["1|a1|1|10", "1|a2|1|10", "2|b|2|20", "2|b|2|21"],
943 );
944 }
945
946 #[tokio::test]
950 async fn a_build_side_that_fits_stays_in_memory() {
951 let ctx = SessionContext::new();
952 let grace = Arc::new(
953 GraceHashJoinExec::try_new(
954 hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
955 4,
956 64 * 1024 * 1024,
957 )
958 .expect("grace join"),
959 );
960 let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
961 assert_eq!(spill_files(&grace), 0, "a fitting build side spilled");
962 assert_eq!(
963 actual,
964 cells(hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None), &ctx).await
965 );
966 }
967
968 #[tokio::test]
973 async fn null_keys_survive_partitioning_under_both_null_equalities() {
974 let ctx = SessionContext::new();
975 for null_equality in [NullEquality::NullEqualsNothing, NullEquality::NullEqualsNull] {
976 let join = || {
977 Arc::new(
978 HashJoinExec::try_new(
979 source(
980 build_schema(),
981 vec![build_batch(
982 vec![None, Some(1), None],
983 vec![Some("n1"), Some("a"), Some("n2")],
984 )],
985 ),
986 source(
987 probe_schema(),
988 vec![probe_batch(vec![None, Some(1)], vec![Some(99), Some(10)])],
989 ),
990 vec![(
991 Arc::new(Column::new("k", 0)),
992 Arc::new(Column::new("k", 0)),
993 )],
994 None,
995 &JoinType::Full,
996 None,
997 PartitionMode::CollectLeft,
998 null_equality,
999 false,
1000 )
1001 .expect("hash join"),
1002 )
1003 };
1004 let expected = cells(join(), &ctx).await;
1005 let grace =
1006 Arc::new(GraceHashJoinExec::try_new(join(), 4, 1).expect("grace join"));
1007 let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
1008 assert!(spill_files(&grace) > 0, "{null_equality:?} stayed in memory");
1009 assert_eq!(actual, expected, "{null_equality:?} disagreed");
1010 }
1011 }
1012
1013 #[tokio::test]
1026 async fn bucket_reservations_are_released_after_each_run() {
1027 use datafusion::execution::memory_pool::GreedyMemoryPool;
1028 use datafusion::execution::runtime_env::RuntimeEnvBuilder;
1029
1030 let env = RuntimeEnvBuilder::new()
1033 .with_memory_pool(Arc::new(GreedyMemoryPool::new(4 * 1024 * 1024)))
1034 .build_arc()
1035 .expect("runtime env");
1036 let ctx = SessionContext::new_with_config_rt(Default::default(), env);
1037
1038 let mut previous: Option<Vec<String>> = None;
1039 for run in 0..4 {
1040 let grace = Arc::new(
1041 GraceHashJoinExec::try_new(
1042 hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
1043 4,
1044 1,
1045 )
1046 .expect("grace join"),
1047 );
1048 assert!(
1049 spill_files(&grace) == 0,
1050 "fresh node should not report spills before running"
1051 );
1052 let rows = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
1053 assert!(!rows.is_empty(), "run {run} produced nothing");
1054 if let Some(first) = &previous {
1055 assert_eq!(&rows, first, "run {run} disagreed with the first run");
1056 }
1057 previous = Some(rows);
1058 }
1059 }
1060
1061 #[tokio::test]
1069 async fn a_projected_join_keeps_its_projection() {
1070 let ctx = SessionContext::new();
1071 let projection = Some(vec![1, 3]);
1073 let expected = cells(
1074 hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, projection.clone()),
1075 &ctx,
1076 )
1077 .await;
1078
1079 let grace = Arc::new(
1080 GraceHashJoinExec::try_new(
1081 hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, projection),
1082 4,
1083 1,
1084 )
1085 .expect("grace join"),
1086 );
1087 assert_eq!(
1088 grace.schema().fields().len(),
1089 2,
1090 "the projection was lost from the output schema"
1091 );
1092 let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
1093 assert!(spill_files(&grace) > 0, "took the in-memory path");
1094 assert_eq!(actual, expected);
1095 }
1096
1097 #[test]
1100 fn the_node_reports_the_same_schema_and_partitioning_as_its_template() {
1101 let template = hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None);
1102 let grace =
1103 GraceHashJoinExec::try_new(Arc::clone(&template), 4, 1).expect("grace join");
1104 assert_eq!(grace.schema(), template.schema());
1105 assert_eq!(
1106 format!("{:?}", grace.properties().partitioning),
1107 format!("{:?}", template.properties().partitioning),
1108 );
1109 }
1110
1111 #[test]
1115 fn a_join_whose_sides_differ_in_partition_count_is_refused() {
1116 let template = Arc::new(
1117 HashJoinExec::try_new(
1118 memory_source(vec![build_rows().clone()], build_schema()).unwrap(),
1120 memory_source(
1121 vec![vec![probe_rows()[0].clone()], vec![probe_rows()[1].clone()]],
1122 probe_schema(),
1123 )
1124 .unwrap(),
1125 vec![(
1126 Arc::new(Column::new("k", 0)),
1127 Arc::new(Column::new("k", 0)),
1128 )],
1129 None,
1130 &JoinType::Inner,
1131 None,
1132 PartitionMode::CollectLeft,
1133 NullEquality::NullEqualsNothing,
1134 false,
1135 )
1136 .expect("hash join"),
1137 );
1138 let refused = GraceHashJoinExec::try_new(template, 4, 1);
1139 assert!(
1140 refused.is_err(),
1141 "a broadcast join must be refused, not silently mis-executed"
1142 );
1143 }
1144
1145 #[tokio::test]
1148 async fn an_empty_build_side_still_emits_unmatched_probe_rows() {
1149 let ctx = SessionContext::new();
1150 let join = || {
1151 Arc::new(
1152 HashJoinExec::try_new(
1153 source(build_schema(), vec![]),
1154 source(probe_schema(), probe_rows()),
1155 vec![(
1156 Arc::new(Column::new("k", 0)),
1157 Arc::new(Column::new("k", 0)),
1158 )],
1159 None,
1160 &JoinType::Right,
1161 None,
1162 PartitionMode::CollectLeft,
1163 NullEquality::NullEqualsNothing,
1164 false,
1165 )
1166 .expect("hash join"),
1167 )
1168 };
1169 let expected = cells(join(), &ctx).await;
1170 let grace = Arc::new(GraceHashJoinExec::try_new(join(), 4, 1).expect("grace join"));
1171 assert_eq!(cells(grace, &ctx).await, expected);
1172 assert_eq!(expected.len(), 4, "every probe row should be reported");
1173 }
1174
1175 #[tokio::test]
1178 async fn far_more_buckets_than_keys_changes_nothing() {
1179 let ctx = SessionContext::new();
1180 let expected = cells(
1181 hash_join(JoinType::Full, NullEquality::NullEqualsNothing, None),
1182 &ctx,
1183 )
1184 .await;
1185 let grace = Arc::new(
1186 GraceHashJoinExec::try_new(
1187 hash_join(JoinType::Full, NullEquality::NullEqualsNothing, None),
1188 256,
1189 1,
1190 )
1191 .expect("grace join"),
1192 );
1193 assert_eq!(cells(grace, &ctx).await, expected);
1194 }
1195
1196 #[test]
1197 fn the_bucket_count_grows_with_the_build_side() {
1198 assert_eq!(bucket_count(1024, 1024 * 1024), DEFAULT_BUCKETS);
1200 let big = bucket_count(10 * 1024 * 1024 * 1024, 256 * 1024 * 1024);
1203 assert!(big > DEFAULT_BUCKETS, "expected more than the floor, got {big}");
1204 assert!(big <= MAX_BUCKETS);
1205 assert!(bucket_count(1, 0) >= MIN_BUCKETS);
1207 }
1208}