use arrow::datatypes::SchemaRef;
use arrow::record_batch::RecordBatch;
use datafusion::common::{Result, Statistics};
use datafusion::error::DataFusionError;
use datafusion::execution::TaskContext;
use datafusion::execution::disk_manager::RefCountedTempFile;
use datafusion::execution::memory_pool::MemoryConsumer;
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
use datafusion::physical_plan::metrics::{ExecutionPlanMetricsSet, MetricsSet, SpillMetrics, Time};
use datafusion::physical_plan::repartition::BatchPartitioner;
use datafusion::physical_plan::spill::{SpillManager, get_record_batch_memory_size};
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, Distribution, ExecutionPlan, PlanProperties,
SendableRecordBatchStream,
};
use futures::{StreamExt, TryStreamExt};
use std::fmt;
use std::sync::Arc;
pub const GRACE_HASH_JOIN_ENV: &str = "KRISHIV_GRACE_HASH_JOIN";
pub const GRACE_HASH_JOIN_BUCKETS_ENV: &str = "KRISHIV_GRACE_HASH_JOIN_BUCKETS";
const DEFAULT_BUCKETS: usize = 32;
const MIN_BUCKETS: usize = 2;
const MAX_BUCKETS: usize = 256;
#[must_use]
pub fn enabled() -> bool {
std::env::var(GRACE_HASH_JOIN_ENV).is_ok_and(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes" || v == "on"
})
}
#[must_use]
pub fn bucket_count(build_bytes: u64, budget: u64) -> usize {
if let Some(override_buckets) = std::env::var(GRACE_HASH_JOIN_BUCKETS_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n > 0)
{
return override_buckets.clamp(MIN_BUCKETS, MAX_BUCKETS);
}
let target = (budget / 2).max(1);
let needed = usize::try_from(build_bytes.div_ceil(target)).unwrap_or(MAX_BUCKETS);
needed.max(DEFAULT_BUCKETS).clamp(MIN_BUCKETS, MAX_BUCKETS)
}
#[derive(Debug)]
pub struct GraceHashJoinExec {
template: Arc<HashJoinExec>,
buckets: usize,
build_budget: usize,
metrics: ExecutionPlanMetricsSet,
}
impl GraceHashJoinExec {
pub fn try_new(
template: Arc<HashJoinExec>,
buckets: usize,
build_budget: usize,
) -> Result<Self> {
use datafusion::physical_plan::ExecutionPlanProperties;
let left = template.left().output_partitioning().partition_count();
let right = template.right().output_partitioning().partition_count();
if left != right {
return Err(DataFusionError::Plan(format!(
"grace hash join needs both sides partitioned alike, got {left} and {right} \
(mode {:?})",
template.partition_mode()
)));
}
Ok(Self {
template,
buckets: buckets.clamp(MIN_BUCKETS, MAX_BUCKETS),
build_budget: build_budget.max(1),
metrics: ExecutionPlanMetricsSet::new(),
})
}
#[must_use]
pub fn template(&self) -> &Arc<HashJoinExec> {
&self.template
}
#[must_use]
pub fn buckets(&self) -> usize {
self.buckets
}
#[must_use]
pub fn build_budget(&self) -> usize {
self.build_budget
}
}
impl DisplayAs for GraceHashJoinExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match t {
DisplayFormatType::Default | DisplayFormatType::TreeRender => write!(
f,
"GraceHashJoinExec: join_type={:?}, buckets={}",
self.template.join_type(),
self.buckets
),
DisplayFormatType::Verbose => write!(
f,
"GraceHashJoinExec: join_type={:?}, buckets={}, build_budget={}, on={:?}",
self.template.join_type(),
self.buckets,
self.build_budget,
self.template.on()
),
}
}
}
impl ExecutionPlan for GraceHashJoinExec {
fn name(&self) -> &str {
"GraceHashJoinExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
self.template.properties()
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![self.template.left(), self.template.right()]
}
fn required_input_distribution(&self) -> Vec<Distribution> {
self.template.required_input_distribution()
}
fn maintains_input_order(&self) -> Vec<bool> {
vec![false, false]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
let template = self
.template
.builder()
.reset_state()
.with_new_children(children)?
.build()?;
Ok(Arc::new(Self::try_new(
Arc::new(template),
self.buckets,
self.build_budget,
)?))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
let schema = self.template.schema();
let template = Arc::clone(&self.template);
let metrics = self.metrics.clone();
let buckets = self.buckets;
let build_budget = self.build_budget;
let started = futures::stream::once(async move {
join(template, buckets, build_budget, partition, context, metrics).await
})
.try_flatten();
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, started)))
}
fn metrics(&self) -> Option<MetricsSet> {
Some(self.metrics.clone_inner())
}
fn partition_statistics(&self, partition: Option<usize>) -> Result<Arc<Statistics>> {
self.template.partition_statistics(partition)
}
}
async fn join(
template: Arc<HashJoinExec>,
buckets: usize,
build_budget: usize,
partition: usize,
context: Arc<TaskContext>,
metrics: ExecutionPlanMetricsSet,
) -> Result<SendableRecordBatchStream> {
let output_schema = template.schema();
let build_schema = template.left().schema();
let probe_schema = template.right().schema();
let mut build_stream = template.left().execute(partition, Arc::clone(&context))?;
let reservation = MemoryConsumer::new(format!("GraceHashJoinBuild[{partition}]"))
.with_can_spill(true)
.register(context.memory_pool());
let mut buffered: Vec<RecordBatch> = Vec::new();
let mut buffered_bytes: usize = 0;
let mut overflowed = false;
while let Some(batch) = build_stream.next().await {
let batch = batch?;
if batch.num_rows() == 0 {
continue;
}
let size = get_record_batch_memory_size(&batch);
if buffered_bytes.saturating_add(size) > build_budget || reservation.try_grow(size).is_err()
{
overflowed = true;
buffered.push(batch);
break;
}
buffered_bytes += size;
buffered.push(batch);
}
if !overflowed {
tracing::debug!(
partition,
buffered_bytes,
batches = buffered.len(),
"grace-hash-join: build side fits, joining in memory"
);
drop(reservation);
let probe = template.right().execute(partition, Arc::clone(&context))?;
let build_exec = memory_source(vec![buffered], build_schema)?;
let probe_exec = Arc::new(OnceStreamExec::new(probe_schema, probe));
return bucket_join(&template, build_exec, probe_exec)?.execute(0, context);
}
tracing::info!(
partition,
buffered_bytes,
build_budget,
buckets,
"grace-hash-join: build side exceeds the budget, partitioning to disk"
);
let build_keys: Vec<Arc<dyn PhysicalExpr>> =
template.on().iter().map(|(l, _)| Arc::clone(l)).collect();
let probe_keys: Vec<Arc<dyn PhysicalExpr>> =
template.on().iter().map(|(_, r)| Arc::clone(r)).collect();
let build_spills = SpillManager::new(
context.runtime_env(),
SpillMetrics::new(&metrics, partition),
Arc::clone(&build_schema),
);
let probe_spills = SpillManager::new(
context.runtime_env(),
SpillMetrics::new(&metrics, partition),
Arc::clone(&probe_schema),
);
let build_files = spill_by_bucket(
std::mem::take(&mut buffered),
build_stream,
build_keys,
buckets,
&build_spills,
"grace hash join build side",
)
.await?;
drop(reservation);
let probe_stream = template.right().execute(partition, Arc::clone(&context))?;
let probe_files = spill_by_bucket(
Vec::new(),
probe_stream,
probe_keys,
buckets,
&probe_spills,
"grace hash join probe side",
)
.await?;
let pairs: Vec<(usize, Option<RefCountedTempFile>, Option<RefCountedTempFile>)> = build_files
.into_iter()
.zip(probe_files)
.enumerate()
.map(|(bucket, (build, probe))| (bucket, build, probe))
.collect();
let joined = futures::stream::iter(pairs)
.map(Ok::<_, DataFusionError>)
.and_then(move |(bucket, build_file, probe_file)| {
let template = Arc::clone(&template);
let context = Arc::clone(&context);
let build_spills = build_spills.clone();
let probe_spills = probe_spills.clone();
let build_schema = Arc::clone(&build_schema);
let probe_schema = Arc::clone(&probe_schema);
async move {
join_bucket(
&template,
bucket,
build_file,
probe_file,
&build_spills,
&probe_spills,
build_schema,
probe_schema,
build_budget,
&context,
)
.await
}
})
.try_flatten();
Ok(Box::pin(RecordBatchStreamAdapter::new(
output_schema,
joined,
)))
}
#[expect(
clippy::too_many_arguments,
reason = "one bucket needs both sides' files, spill managers and schemas; \
bundling them into a struct would only move the list"
)]
async fn join_bucket(
template: &Arc<HashJoinExec>,
bucket: usize,
build_file: Option<RefCountedTempFile>,
probe_file: Option<RefCountedTempFile>,
build_spills: &SpillManager,
probe_spills: &SpillManager,
build_schema: SchemaRef,
probe_schema: SchemaRef,
build_budget: usize,
context: &Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
if build_file.is_none() && probe_file.is_none() {
return Ok(Box::pin(RecordBatchStreamAdapter::new(
template.schema(),
futures::stream::empty(),
)));
}
let build: Vec<RecordBatch> = match build_file {
Some(file) => {
build_spills
.read_spill_as_stream(file, None)?
.try_collect()
.await?
}
None => Vec::new(),
};
let bucket_bytes: usize = build.iter().map(get_record_batch_memory_size).sum();
let reservation = MemoryConsumer::new(format!("GraceHashJoinBucket[{bucket}]"))
.with_can_spill(false)
.register(context.memory_pool());
reservation.try_grow(bucket_bytes)?;
if bucket_bytes > build_budget {
tracing::warn!(
bucket,
bucket_bytes,
build_budget,
"grace-hash-join: bucket build side exceeds the per-task budget \
(key skew); joining it anyway, which may exhaust the pool"
);
} else {
tracing::debug!(bucket, bucket_bytes, "grace-hash-join: joining bucket");
}
let probe: SendableRecordBatchStream = match probe_file {
Some(file) => probe_spills.read_spill_as_stream(file, None)?,
None => Box::pin(RecordBatchStreamAdapter::new(
Arc::clone(&probe_schema),
futures::stream::empty(),
)),
};
let build_exec = memory_source(vec![build], build_schema)?;
let probe_exec = Arc::new(OnceStreamExec::new(probe_schema, probe));
let schema = template.schema();
let joined = bucket_join(template, build_exec, probe_exec)?.execute(0, Arc::clone(context))?;
let guarded = futures::stream::unfold(
(joined, reservation),
|(mut stream, reservation)| async move {
stream
.next()
.await
.map(|batch| (batch, (stream, reservation)))
},
);
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, guarded)))
}
fn memory_source(
partitions: Vec<Vec<RecordBatch>>,
schema: SchemaRef,
) -> Result<Arc<dyn ExecutionPlan>> {
let exec =
datafusion::datasource::memory::MemorySourceConfig::try_new_exec(&partitions, schema, None)?;
Ok(exec)
}
fn bucket_join(
template: &Arc<HashJoinExec>,
build: Arc<dyn ExecutionPlan>,
probe: Arc<dyn ExecutionPlan>,
) -> Result<Arc<dyn ExecutionPlan>> {
template
.builder()
.reset_state()
.with_new_children(vec![build, probe])?
.with_partition_mode(PartitionMode::CollectLeft)
.recompute_properties()
.build_exec()
}
async fn spill_by_bucket(
prefix: Vec<RecordBatch>,
stream: SendableRecordBatchStream,
keys: Vec<Arc<dyn PhysicalExpr>>,
buckets: usize,
spills: &SpillManager,
request: &str,
) -> Result<Vec<Option<RefCountedTempFile>>> {
let mut partitioner = BatchPartitioner::new_hash_partitioner(keys, buckets, Time::new())?;
let mut files = Vec::with_capacity(buckets);
for bucket in 0..buckets {
files.push(spills.create_in_progress_file(&format!("{request} bucket {bucket}"))?);
}
let mut all = futures::stream::iter(prefix.into_iter().map(Ok)).chain(stream);
while let Some(batch) = all.next().await {
let batch = batch?;
if batch.num_rows() == 0 {
continue;
}
partitioner.partition(batch, |bucket, part| {
if part.num_rows() == 0 {
return Ok(());
}
let file = files.get_mut(bucket).ok_or_else(|| {
DataFusionError::Internal(format!(
"grace hash join routed a batch to bucket {bucket} of {buckets}"
))
})?;
file.append_batch(&part)?;
Ok(())
})?;
}
let mut finished = Vec::with_capacity(buckets);
for mut file in files {
finished.push(file.finish()?);
}
Ok(finished)
}
struct OnceStreamExec {
stream: std::sync::Mutex<Option<SendableRecordBatchStream>>,
properties: Arc<PlanProperties>,
}
impl fmt::Debug for OnceStreamExec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("OnceStreamExec")
}
}
impl OnceStreamExec {
fn new(schema: SchemaRef, stream: SendableRecordBatchStream) -> Self {
use datafusion::physical_expr::EquivalenceProperties;
use datafusion::physical_plan::Partitioning;
use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
let properties = Arc::new(PlanProperties::new(
EquivalenceProperties::new(schema),
Partitioning::UnknownPartitioning(1),
EmissionType::Incremental,
Boundedness::Bounded,
));
Self {
stream: std::sync::Mutex::new(Some(stream)),
properties,
}
}
}
impl DisplayAs for OnceStreamExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "OnceStreamExec")
}
}
impl ExecutionPlan for OnceStreamExec {
fn name(&self) -> &str {
"OnceStreamExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![]
}
fn with_new_children(
self: Arc<Self>,
_children: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
Ok(self)
}
fn execute(
&self,
partition: usize,
_context: Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
if partition != 0 {
return Err(DataFusionError::Internal(format!(
"OnceStreamExec has one partition, asked for {partition}"
)));
}
self.stream
.lock()
.map_err(|_| DataFusionError::Internal("OnceStreamExec mutex poisoned".into()))?
.take()
.ok_or_else(|| DataFusionError::Internal("OnceStreamExec was already executed".into()))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use arrow::array::{Int32Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::{JoinType, NullEquality};
use datafusion::physical_expr::expressions::Column;
use datafusion::physical_plan::collect;
use datafusion::prelude::SessionContext;
fn build_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("k", DataType::Int32, true),
Field::new("v", DataType::Utf8, true),
]))
}
fn probe_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("k", DataType::Int32, true),
Field::new("w", DataType::Int32, true),
]))
}
fn build_batch(keys: Vec<Option<i32>>, vals: Vec<Option<&str>>) -> RecordBatch {
RecordBatch::try_new(
build_schema(),
vec![
Arc::new(Int32Array::from(keys)),
Arc::new(StringArray::from(vals)),
],
)
.expect("build batch")
}
fn probe_batch(keys: Vec<Option<i32>>, ws: Vec<Option<i32>>) -> RecordBatch {
RecordBatch::try_new(
probe_schema(),
vec![
Arc::new(Int32Array::from(keys)),
Arc::new(Int32Array::from(ws)),
],
)
.expect("probe batch")
}
fn build_rows() -> Vec<RecordBatch> {
vec![
build_batch(vec![Some(1), Some(1), Some(2)], vec![Some("a1"), Some("a2"), Some("b")]),
build_batch(vec![Some(3), Some(7)], vec![Some("c"), Some("g")]),
]
}
fn probe_rows() -> Vec<RecordBatch> {
vec![
probe_batch(vec![Some(1), Some(2)], vec![Some(10), Some(20)]),
probe_batch(vec![Some(2), Some(4)], vec![Some(21), Some(40)]),
]
}
fn source(schema: SchemaRef, batches: Vec<RecordBatch>) -> Arc<dyn ExecutionPlan> {
memory_source(vec![batches], schema).expect("memory source")
}
fn hash_join(
join_type: JoinType,
null_equality: NullEquality,
projection: Option<Vec<usize>>,
) -> Arc<HashJoinExec> {
Arc::new(
HashJoinExec::try_new(
source(build_schema(), build_rows()),
source(probe_schema(), probe_rows()),
vec![(
Arc::new(Column::new("k", 0)),
Arc::new(Column::new("k", 0)),
)],
None,
&join_type,
projection,
PartitionMode::CollectLeft,
null_equality,
false,
)
.expect("hash join"),
)
}
async fn cells(plan: Arc<dyn ExecutionPlan>, ctx: &SessionContext) -> Vec<String> {
let batches = collect(plan, ctx.task_ctx()).await.expect("collect");
let mut rows: Vec<String> = batches
.iter()
.flat_map(|b| {
(0..b.num_rows()).map(move |r| {
(0..b.num_columns())
.map(|c| {
arrow::util::display::array_value_to_string(b.column(c), r)
.expect("cell")
})
.collect::<Vec<_>>()
.join("|")
})
})
.collect();
rows.sort();
rows
}
fn spill_files(plan: &GraceHashJoinExec) -> usize {
plan.metrics().and_then(|m| m.spill_count()).unwrap_or(0)
}
#[tokio::test]
async fn every_join_type_agrees_with_the_hash_join_it_replaces() {
let ctx = SessionContext::new();
for join_type in [
JoinType::Inner,
JoinType::Left,
JoinType::Right,
JoinType::Full,
JoinType::LeftSemi,
JoinType::LeftAnti,
JoinType::RightSemi,
JoinType::RightAnti,
] {
let expected = cells(hash_join(join_type, NullEquality::NullEqualsNothing, None), &ctx).await;
let grace = Arc::new(
GraceHashJoinExec::try_new(
hash_join(join_type, NullEquality::NullEqualsNothing, None),
4,
1,
)
.expect("grace join"),
);
let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
assert!(
spill_files(&grace) > 0,
"{join_type:?} took the in-memory path, so this proved nothing"
);
assert_eq!(actual, expected, "{join_type:?} disagreed after partitioning");
}
}
#[tokio::test]
async fn the_inner_join_returns_the_rows_it_should() {
let ctx = SessionContext::new();
let grace = Arc::new(
GraceHashJoinExec::try_new(
hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
4,
1,
)
.expect("grace join"),
);
assert_eq!(
cells(grace, &ctx).await,
vec!["1|a1|1|10", "1|a2|1|10", "2|b|2|20", "2|b|2|21"],
);
}
#[tokio::test]
async fn a_build_side_that_fits_stays_in_memory() {
let ctx = SessionContext::new();
let grace = Arc::new(
GraceHashJoinExec::try_new(
hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
4,
64 * 1024 * 1024,
)
.expect("grace join"),
);
let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
assert_eq!(spill_files(&grace), 0, "a fitting build side spilled");
assert_eq!(
actual,
cells(hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None), &ctx).await
);
}
#[tokio::test]
async fn null_keys_survive_partitioning_under_both_null_equalities() {
let ctx = SessionContext::new();
for null_equality in [NullEquality::NullEqualsNothing, NullEquality::NullEqualsNull] {
let join = || {
Arc::new(
HashJoinExec::try_new(
source(
build_schema(),
vec![build_batch(
vec![None, Some(1), None],
vec![Some("n1"), Some("a"), Some("n2")],
)],
),
source(
probe_schema(),
vec![probe_batch(vec![None, Some(1)], vec![Some(99), Some(10)])],
),
vec![(
Arc::new(Column::new("k", 0)),
Arc::new(Column::new("k", 0)),
)],
None,
&JoinType::Full,
None,
PartitionMode::CollectLeft,
null_equality,
false,
)
.expect("hash join"),
)
};
let expected = cells(join(), &ctx).await;
let grace =
Arc::new(GraceHashJoinExec::try_new(join(), 4, 1).expect("grace join"));
let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
assert!(spill_files(&grace) > 0, "{null_equality:?} stayed in memory");
assert_eq!(actual, expected, "{null_equality:?} disagreed");
}
}
#[tokio::test]
async fn bucket_reservations_are_released_after_each_run() {
use datafusion::execution::memory_pool::GreedyMemoryPool;
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
let env = RuntimeEnvBuilder::new()
.with_memory_pool(Arc::new(GreedyMemoryPool::new(4 * 1024 * 1024)))
.build_arc()
.expect("runtime env");
let ctx = SessionContext::new_with_config_rt(Default::default(), env);
let mut previous: Option<Vec<String>> = None;
for run in 0..4 {
let grace = Arc::new(
GraceHashJoinExec::try_new(
hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
4,
1,
)
.expect("grace join"),
);
assert!(
spill_files(&grace) == 0,
"fresh node should not report spills before running"
);
let rows = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
assert!(!rows.is_empty(), "run {run} produced nothing");
if let Some(first) = &previous {
assert_eq!(&rows, first, "run {run} disagreed with the first run");
}
previous = Some(rows);
}
}
#[tokio::test]
async fn a_projected_join_keeps_its_projection() {
let ctx = SessionContext::new();
let projection = Some(vec![1, 3]);
let expected = cells(
hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, projection.clone()),
&ctx,
)
.await;
let grace = Arc::new(
GraceHashJoinExec::try_new(
hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, projection),
4,
1,
)
.expect("grace join"),
);
assert_eq!(
grace.schema().fields().len(),
2,
"the projection was lost from the output schema"
);
let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
assert!(spill_files(&grace) > 0, "took the in-memory path");
assert_eq!(actual, expected);
}
#[test]
fn the_node_reports_the_same_schema_and_partitioning_as_its_template() {
let template = hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None);
let grace =
GraceHashJoinExec::try_new(Arc::clone(&template), 4, 1).expect("grace join");
assert_eq!(grace.schema(), template.schema());
assert_eq!(
format!("{:?}", grace.properties().partitioning),
format!("{:?}", template.properties().partitioning),
);
}
#[test]
fn a_join_whose_sides_differ_in_partition_count_is_refused() {
let template = Arc::new(
HashJoinExec::try_new(
memory_source(vec![build_rows().clone()], build_schema()).unwrap(),
memory_source(
vec![vec![probe_rows()[0].clone()], vec![probe_rows()[1].clone()]],
probe_schema(),
)
.unwrap(),
vec![(
Arc::new(Column::new("k", 0)),
Arc::new(Column::new("k", 0)),
)],
None,
&JoinType::Inner,
None,
PartitionMode::CollectLeft,
NullEquality::NullEqualsNothing,
false,
)
.expect("hash join"),
);
let refused = GraceHashJoinExec::try_new(template, 4, 1);
assert!(
refused.is_err(),
"a broadcast join must be refused, not silently mis-executed"
);
}
#[tokio::test]
async fn an_empty_build_side_still_emits_unmatched_probe_rows() {
let ctx = SessionContext::new();
let join = || {
Arc::new(
HashJoinExec::try_new(
source(build_schema(), vec![]),
source(probe_schema(), probe_rows()),
vec![(
Arc::new(Column::new("k", 0)),
Arc::new(Column::new("k", 0)),
)],
None,
&JoinType::Right,
None,
PartitionMode::CollectLeft,
NullEquality::NullEqualsNothing,
false,
)
.expect("hash join"),
)
};
let expected = cells(join(), &ctx).await;
let grace = Arc::new(GraceHashJoinExec::try_new(join(), 4, 1).expect("grace join"));
assert_eq!(cells(grace, &ctx).await, expected);
assert_eq!(expected.len(), 4, "every probe row should be reported");
}
#[tokio::test]
async fn far_more_buckets_than_keys_changes_nothing() {
let ctx = SessionContext::new();
let expected = cells(
hash_join(JoinType::Full, NullEquality::NullEqualsNothing, None),
&ctx,
)
.await;
let grace = Arc::new(
GraceHashJoinExec::try_new(
hash_join(JoinType::Full, NullEquality::NullEqualsNothing, None),
256,
1,
)
.expect("grace join"),
);
assert_eq!(cells(grace, &ctx).await, expected);
}
#[test]
fn the_bucket_count_grows_with_the_build_side() {
assert_eq!(bucket_count(1024, 1024 * 1024), DEFAULT_BUCKETS);
let big = bucket_count(10 * 1024 * 1024 * 1024, 256 * 1024 * 1024);
assert!(big > DEFAULT_BUCKETS, "expected more than the floor, got {big}");
assert!(big <= MAX_BUCKETS);
assert!(bucket_count(1, 0) >= MIN_BUCKETS);
}
}