use datafusion::common::config::ConfigOptions;
use datafusion::common::stats::Precision;
use datafusion::common::tree_node::{Transformed, TreeNode};
use datafusion::error::Result;
use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
use datafusion::physical_optimizer::PhysicalOptimizerRule;
use datafusion::physical_plan::joins::utils::JoinFilter;
use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode, SortMergeJoinExec};
use datafusion::physical_plan::repartition::RepartitionExec;
use datafusion::physical_plan::sorts::sort::SortExec;
use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties, Partitioning};
use std::sync::Arc;
pub const SPILL_JOIN_BUILD_BYTES_ENV: &str = "KRISHIV_SPILL_JOIN_BUILD_BYTES";
const BUILD_FRACTION_OF_TASK_SHARE: f64 = 0.5;
fn estimated_build_bytes_from_rows(
stats: &datafusion::common::Statistics,
build_schema: &arrow::datatypes::Schema,
) -> Option<u64> {
let rows = match stats.num_rows {
Precision::Exact(rows) | Precision::Inexact(rows) => rows,
Precision::Absent => return None,
};
let row_width = crate::join_estimates::estimated_row_width(build_schema);
u64::try_from(rows.saturating_mul(row_width)).ok()
}
fn build_bytes_estimate(hash_join: &HashJoinExec) -> Option<u64> {
let estimate = crate::join_estimates::BuildSideEstimate::of(hash_join.left());
if estimate.is_unknown() {
return None;
}
if estimate.any_claims_empty() {
return Some(DEGENERATE_BUILD_BYTES);
}
let stats = hash_join.left().partition_statistics(None).ok()?;
match stats.total_byte_size {
Precision::Exact(bytes) | Precision::Inexact(bytes) => u64::try_from(bytes).ok(),
Precision::Absent => {
estimated_build_bytes_from_rows(&stats, &hash_join.left().schema())
}
}
}
fn left_first_filter(filter: &JoinFilter) -> Option<JoinFilter> {
use datafusion::common::JoinSide;
use datafusion::physical_expr::expressions::Column;
let indices = filter.column_indices();
let mut order: Vec<usize> = Vec::with_capacity(indices.len());
order.extend(
indices
.iter()
.enumerate()
.filter(|(_, ci)| ci.side == JoinSide::Left)
.map(|(at, _)| at),
);
order.extend(
indices
.iter()
.enumerate()
.filter(|(_, ci)| ci.side == JoinSide::Right)
.map(|(at, _)| at),
);
if order.len() != indices.len() {
return None;
}
if order.iter().enumerate().all(|(to, from)| to == *from) {
return Some(filter.clone());
}
let mut moved_to = vec![0usize; indices.len()];
for (to, &from) in order.iter().enumerate() {
*moved_to.get_mut(from)? = to;
}
let fields = filter.schema().fields();
let mut permuted = Vec::with_capacity(order.len());
for &from in &order {
permuted.push(fields.get(from)?.as_ref().clone());
}
let schema = Arc::new(arrow::datatypes::Schema::new(permuted));
let column_indices: Vec<_> = order
.iter()
.map(|&from| indices.get(from).cloned())
.collect::<Option<Vec<_>>>()?;
type Expr = Arc<dyn datafusion::physical_expr::PhysicalExpr>;
let original: Expr = Arc::clone(filter.expression());
let expression = original
.transform(|node: Expr| {
let any = node.as_ref() as &dyn std::any::Any;
let Some(column) = any.downcast_ref::<Column>() else {
return Ok(Transformed::no(node));
};
let Some(&to) = moved_to.get(column.index()) else {
return Err(datafusion::error::DataFusionError::Internal(format!(
"join filter column {} is outside its {}-column intermediate schema",
column.index(),
moved_to.len()
)));
};
Ok(Transformed::yes(Arc::new(Column::new(column.name(), to)) as Expr))
})
.ok()?
.data;
Some(JoinFilter::new(expression, column_indices, schema))
}
const DEGENERATE_BUILD_BYTES: u64 = u64::MAX;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct JoinFacts {
bytes: Option<u64>,
convertible: bool,
}
impl JoinFacts {
fn retained_bytes(self) -> u64 {
self.bytes.unwrap_or(0)
}
fn budget_bytes(self, assumed_share: u64) -> u64 {
if self.bytes == Some(DEGENERATE_BUILD_BYTES) {
assumed_share
} else {
self.retained_bytes()
}
}
fn is_candidate(self) -> bool {
self.convertible && self.bytes.is_some()
}
}
fn unknown_build_pressure(facts: &[JoinFacts], threshold: u64) -> u64 {
let unknown = facts.iter().filter(|f| f.bytes.is_none()).count();
if unknown == 0 || facts.is_empty() {
return 0;
}
let share = threshold / facts.len() as u64;
share.saturating_mul(unknown as u64)
}
fn collect_join_facts(
plan: &Arc<dyn ExecutionPlan>,
target_partitions: usize,
rescue_degenerate_broadcast: bool,
out: &mut Vec<JoinFacts>,
) {
for child in plan.children() {
collect_join_facts(child, target_partitions, rescue_degenerate_broadcast, out);
}
let any = plan.as_ref() as &dyn std::any::Any;
if let Some(hash_join) = any.downcast_ref::<HashJoinExec>() {
out.push(JoinFacts {
bytes: build_bytes_estimate(hash_join),
convertible: convertible_mode(hash_join, target_partitions, rescue_degenerate_broadcast)
.is_some(),
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Conversion {
InPlace { preserve_partitioning: bool },
Repartition { partitions: usize },
}
fn convertible_mode(
hash_join: &HashJoinExec,
target_partitions: usize,
rescue_degenerate_broadcast: bool,
) -> Option<Conversion> {
let single_partition = hash_join.left().output_partitioning().partition_count() == 1
&& hash_join.right().output_partitioning().partition_count() == 1;
match hash_join.partition_mode() {
PartitionMode::Partitioned => Some(Conversion::InPlace {
preserve_partitioning: true,
}),
PartitionMode::CollectLeft if single_partition => Some(Conversion::InPlace {
preserve_partitioning: false,
}),
PartitionMode::CollectLeft
if rescue_degenerate_broadcast
&& target_partitions > 1
&& !hash_join.on().is_empty()
&& crate::join_estimates::BuildSideEstimate::of(hash_join.left())
.any_claims_empty() =>
{
Some(Conversion::Repartition {
partitions: target_partitions,
})
}
_ => None,
}
}
#[derive(Debug)]
pub struct SpillableJoinSelection {
threshold_bytes: Option<u64>,
grace: bool,
rescue_degenerate_broadcast: bool,
}
impl SpillableJoinSelection {
pub fn from_capacity() -> Self {
let threshold_bytes = std::env::var(SPILL_JOIN_BUILD_BYTES_ENV)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
.or_else(|| {
let share = krishiv_common::executor_capacity::ExecutorCapacity::detect_cached()
.min_task_memory_share_bytes()?;
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "byte counts are far below f64's exact-integer range"
)]
Some((share as f64 * BUILD_FRACTION_OF_TASK_SHARE) as u64)
});
Self {
threshold_bytes,
grace: false,
rescue_degenerate_broadcast:
krishiv_common::executor_capacity::is_single_query_process(),
}
}
#[must_use]
pub fn without_broadcast_rescue(self) -> Self {
Self {
rescue_degenerate_broadcast: false,
..self
}
}
#[must_use]
pub fn for_local_execution() -> Self {
Self {
grace: crate::grace_hash_join::enabled(),
..Self::from_capacity()
}
}
#[must_use]
pub fn with_grace_where_plans_are_never_encoded(self) -> Self {
self.with_grace_gated(
krishiv_common::executor_capacity::is_single_query_process(),
crate::grace_hash_join::enabled(),
)
}
#[must_use]
fn with_grace_gated(self, single_query_process: bool, flag: bool) -> Self {
if single_query_process && flag {
Self { grace: true, ..self }
} else {
self
}
}
fn conversion_decisions(facts: &[JoinFacts], threshold: u64) -> Vec<bool> {
let assumed_share = if facts.is_empty() {
0
} else {
threshold / facts.len() as u64
};
let measured = facts
.iter()
.map(|f| f.budget_bytes(assumed_share))
.fold(0u64, u64::saturating_add);
let unknown_pressure = unknown_build_pressure(facts, threshold);
let total = measured.saturating_add(unknown_pressure);
let degenerate = facts
.iter()
.any(|f| f.bytes == Some(DEGENERATE_BUILD_BYTES));
if !degenerate && total <= threshold {
return vec![false; facts.len()];
}
let unavoidable = facts
.iter()
.filter(|f| !f.is_candidate())
.map(|f| f.budget_bytes(assumed_share))
.fold(0u64, u64::saturating_add)
.saturating_add(unknown_pressure);
let mut budget = threshold.saturating_sub(unavoidable);
let mut candidates: Vec<(usize, u64)> = facts
.iter()
.enumerate()
.filter(|(_, f)| f.is_candidate())
.map(|(at, f)| (at, f.retained_bytes()))
.collect();
candidates.sort_by_key(|(_, bytes)| *bytes);
let mut convert = vec![false; facts.len()];
for (at, bytes) in candidates {
if bytes <= budget {
budget -= bytes;
} else if let Some(slot) = convert.get_mut(at) {
*slot = true;
}
}
convert
}
#[must_use]
pub fn with_threshold(threshold_bytes: Option<u64>) -> Self {
Self {
threshold_bytes,
grace: false,
rescue_degenerate_broadcast: true,
}
}
#[must_use]
pub fn with_threshold_and_grace(threshold_bytes: Option<u64>, grace: bool) -> Self {
Self {
threshold_bytes,
grace,
rescue_degenerate_broadcast: true,
}
}
fn grace_join(
&self,
hash_join: &HashJoinExec,
conversion: Conversion,
build_input: &Arc<dyn ExecutionPlan>,
probe_input: &Arc<dyn ExecutionPlan>,
build_bytes: u64,
threshold: u64,
) -> Result<Arc<dyn ExecutionPlan>> {
let mut builder = hash_join.builder().reset_state().with_new_children(vec![
Arc::clone(build_input),
Arc::clone(probe_input),
])?;
if matches!(conversion, Conversion::Repartition { .. }) {
builder = builder
.with_partition_mode(PartitionMode::Partitioned)
.recompute_properties();
}
let template = Arc::new(builder.build()?);
let mode = *template.partition_mode();
let per_task_build_bytes = match mode {
PartitionMode::Partitioned => {
let partitions = template.left().output_partitioning().partition_count().max(1);
build_bytes / partitions as u64
}
_ => build_bytes,
};
let buckets = crate::grace_hash_join::bucket_count(per_task_build_bytes, threshold);
let budget = usize::try_from(threshold).unwrap_or(usize::MAX);
let grace = crate::grace_hash_join::GraceHashJoinExec::try_new(template, buckets, budget)?;
tracing::info!(
build_bytes,
per_task_build_bytes,
threshold,
buckets,
?mode,
join_type = ?hash_join.join_type(),
"hash join build side exceeds per-task memory share; using grace hash join"
);
Ok(Arc::new(grace))
}
fn reapply_projection(
converted: Arc<dyn ExecutionPlan>,
hash_join: &HashJoinExec,
) -> Result<Arc<dyn ExecutionPlan>> {
use datafusion::physical_expr::expressions::Column;
use datafusion::physical_plan::projection::ProjectionExec;
let Some(projection) = hash_join.projection.as_ref() else {
return Ok(converted);
};
let schema = converted.schema();
let mut exprs: Vec<(Arc<dyn datafusion::physical_expr::PhysicalExpr>, String)> =
Vec::with_capacity(projection.len());
for &index in projection.iter() {
let Some(field) = schema.fields().get(index) else {
return datafusion::error::Result::Err(
datafusion::error::DataFusionError::Plan(format!(
"spillable-join: projection index {index} is outside the \
converted join's {} columns",
schema.fields().len()
)),
);
};
exprs.push((
Arc::new(Column::new(field.name(), index)),
field.name().clone(),
));
}
Ok(Arc::new(ProjectionExec::try_new(exprs, converted)?))
}
fn convert(
&self,
hash_join: &HashJoinExec,
threshold: u64,
target_partitions: usize,
) -> Result<Option<Arc<dyn ExecutionPlan>>> {
let Some(conversion) =
convertible_mode(hash_join, target_partitions, self.rescue_degenerate_broadcast)
else {
tracing::debug!(
mode = ?hash_join.partition_mode(),
threshold,
"spillable-join: join mode is not convertible"
);
return Ok(None);
};
let Some(build_bytes) = build_bytes_estimate(hash_join) else {
tracing::debug!(
threshold,
"spillable-join: build-side size and row count both unknown, \
keeping hash join"
);
return Ok(None);
};
let on = hash_join.on();
let (build_input, probe_input, preserve_partitioning) = match conversion {
Conversion::InPlace {
preserve_partitioning,
} => (
Arc::clone(hash_join.left()),
Arc::clone(hash_join.right()),
preserve_partitioning,
),
Conversion::Repartition { partitions } => {
let build_keys: Vec<_> = on.iter().map(|(l, _)| Arc::clone(l)).collect();
let probe_keys: Vec<_> = on.iter().map(|(_, r)| Arc::clone(r)).collect();
let build = RepartitionExec::try_new(
Arc::clone(hash_join.left()),
Partitioning::Hash(build_keys, partitions),
)?;
let probe = RepartitionExec::try_new(
Arc::clone(hash_join.right()),
Partitioning::Hash(probe_keys, partitions),
)?;
tracing::info!(
partitions,
build_bytes,
threshold,
join_type = ?hash_join.join_type(),
"spillable-join: broadcast build side is too large to buffer; \
hash-partitioning both sides so it can spill"
);
(
Arc::new(build) as Arc<dyn ExecutionPlan>,
Arc::new(probe) as Arc<dyn ExecutionPlan>,
true,
)
}
};
if self.grace {
match self.grace_join(
hash_join,
conversion,
&build_input,
&probe_input,
build_bytes,
threshold,
) {
Ok(converted) => return Ok(Some(converted)),
Err(error) => tracing::info!(
%error,
build_bytes,
"spillable-join: grace hash join declined; trying sort-merge"
),
}
}
let left_keys: Vec<PhysicalSortExpr> = on
.iter()
.map(|(l, _)| PhysicalSortExpr::new_default(Arc::clone(l)))
.collect();
let right_keys: Vec<PhysicalSortExpr> = on
.iter()
.map(|(_, r)| PhysicalSortExpr::new_default(Arc::clone(r)))
.collect();
let (Some(left_ordering), Some(right_ordering)) = (
LexOrdering::new(left_keys),
LexOrdering::new(right_keys),
) else {
return Ok(None);
};
let sort_options = left_ordering
.iter()
.map(|sort_expr| sort_expr.options)
.collect();
let sorted_left =
sort_unless_already_sorted(build_input, left_ordering, preserve_partitioning);
let sorted_right =
sort_unless_already_sorted(probe_input, right_ordering, preserve_partitioning);
let filter = match hash_join.filter() {
Some(filter) => match left_first_filter(filter) {
Some(normalised) => Some(normalised),
None => {
tracing::debug!(
"spillable-join: join filter cannot be reordered for sort-merge, \
keeping hash join"
);
return Ok(None);
}
},
None => None,
};
match SortMergeJoinExec::try_new(
sorted_left,
sorted_right,
on.to_vec(),
filter,
*hash_join.join_type(),
sort_options,
hash_join.null_equality(),
) {
Ok(smj) => {
let converted = Self::reapply_projection(Arc::new(smj), hash_join)?;
tracing::info!(
build_bytes,
threshold,
mode = ?hash_join.partition_mode(),
join_type = ?hash_join.join_type(),
projected = hash_join.contains_projection(),
"hash join build side exceeds per-task memory share; using sort-merge join"
);
Ok(Some(converted))
}
Err(error) => {
tracing::debug!(%error, "sort-merge conversion declined; keeping hash join");
Ok(None)
}
}
}
}
fn sort_unless_already_sorted(
input: Arc<dyn ExecutionPlan>,
ordering: LexOrdering,
preserve_partitioning: bool,
) -> Arc<dyn ExecutionPlan> {
let partitions_match =
preserve_partitioning || input.output_partitioning().partition_count() == 1;
if partitions_match
&& input
.equivalence_properties()
.ordering_satisfy(ordering.clone())
.unwrap_or(false)
{
tracing::debug!(
ordering = %ordering,
"spillable-join: input already sorted on the join keys; skipping the sort"
);
return input;
}
Arc::new(SortExec::new(ordering, input).with_preserve_partitioning(preserve_partitioning))
}
impl PhysicalOptimizerRule for SpillableJoinSelection {
fn name(&self) -> &str {
"spillable_join_selection"
}
fn schema_check(&self) -> bool {
true
}
fn optimize(
&self,
plan: Arc<dyn ExecutionPlan>,
config: &ConfigOptions,
) -> Result<Arc<dyn ExecutionPlan>> {
let Some(configured) = self.threshold_bytes else {
tracing::debug!("spillable-join: no memory cap configured, rule inactive");
return Ok(plan);
};
let target_partitions = config.execution.target_partitions.max(1);
let mut facts = Vec::new();
collect_join_facts(
&plan,
target_partitions,
self.rescue_degenerate_broadcast,
&mut facts,
);
let decisions = Self::conversion_decisions(&facts, configured);
let threshold = configured;
let mut at = 0usize;
let mut seen = 0usize;
let mut converted = 0usize;
let mut declined_on_error = 0usize;
let out = plan
.transform_up(|node| {
let any = node.as_ref() as &dyn std::any::Any;
let Some(hash_join) = any.downcast_ref::<HashJoinExec>() else {
return Ok(Transformed::no(node));
};
let index = at;
at += 1;
seen += 1;
if !decisions.get(index).copied().unwrap_or(false) {
return Ok(Transformed::no(node));
}
match self.convert(hash_join, threshold, target_partitions) {
Ok(Some(plan)) => {
converted += 1;
Ok(Transformed::yes(plan))
}
Ok(None) => Ok(Transformed::no(node)),
Err(error) => {
declined_on_error += 1;
tracing::warn!(
%error,
mode = ?hash_join.partition_mode(),
join_type = ?hash_join.join_type(),
"spillable-join: conversion errored; keeping hash join"
);
Ok(Transformed::no(node))
}
}
})
.map(|t| t.data)?;
if seen > 0 {
tracing::info!(
hash_joins = seen,
converted,
declined_on_error,
configured_threshold = configured,
chosen_by_budget = decisions.iter().filter(|d| **d).count(),
unconvertible = facts.iter().filter(|f| !f.convertible).count(),
unmeasurable = facts.iter().filter(|f| f.bytes.is_none()).count(),
"spillable-join: pass complete"
);
}
Ok(out)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use datafusion::prelude::{SessionConfig, SessionContext};
fn partitioned_join_ctx() -> SessionContext {
let mut config = SessionConfig::new().with_target_partitions(4);
config.options_mut().optimizer.hash_join_single_partition_threshold = 0;
config.options_mut().optimizer.hash_join_single_partition_threshold_rows = 0;
SessionContext::new_with_config(config)
}
async fn joined_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
ctx.sql("CREATE TABLE big AS SELECT v % 1000 AS k, v AS payload FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
.await.unwrap().collect().await.unwrap();
ctx.sql("CREATE TABLE small AS SELECT v AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 100)) AS u(v)")
.await.unwrap().collect().await.unwrap();
ctx.sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
.await.unwrap().create_physical_plan().await.unwrap()
}
fn contains(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
datafusion::physical_plan::displayable(plan.as_ref())
.indent(true)
.to_string()
.contains(name)
}
#[tokio::test]
async fn an_oversized_build_side_converts_and_still_answers_correctly() {
let ctx = partitioned_join_ctx();
let plan = joined_plan(&ctx).await;
assert!(contains(&plan, "HashJoinExec"), "precondition: hash join planned");
assert!(
contains(&plan, "mode=Partitioned"),
"precondition: the join must be Partitioned or the rule correctly declines:\n{}",
datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
);
let rule = SpillableJoinSelection::with_threshold(Some(1));
let optimized = rule.optimize(Arc::clone(&plan), &ConfigOptions::default()).unwrap();
assert!(
contains(&optimized, "SortMergeJoin"),
"an over-threshold build side must convert:\n{}",
datafusion::physical_plan::displayable(optimized.as_ref()).indent(true)
);
let baseline_plan = ctx
.sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
.await.unwrap().create_physical_plan().await.unwrap();
let baseline =
datafusion::physical_plan::collect(baseline_plan, ctx.task_ctx()).await.unwrap();
let converted =
datafusion::physical_plan::collect(optimized, ctx.task_ctx()).await.unwrap();
let count = |bs: &[arrow::record_batch::RecordBatch]| -> usize {
bs.iter().map(|b| b.num_rows()).sum()
};
assert_eq!(count(&baseline), count(&converted));
}
#[tokio::test]
async fn grace_over_partitioned_inputs_answers_correctly() {
let ctx = partitioned_join_ctx();
let plan = joined_plan(&ctx).await;
assert!(
contains(&plan, "mode=Partitioned"),
"precondition: the join must be Partitioned or this tests nothing:\n{}",
datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
);
let optimized = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
.optimize(Arc::clone(&plan), &ConfigOptions::default())
.unwrap();
assert!(
contains(&optimized, "GraceHashJoin"),
"grace must be what ran, or the answer proves nothing about it:\n{}",
datafusion::physical_plan::displayable(optimized.as_ref()).indent(true)
);
let baseline_plan = ctx
.sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
.await.unwrap().create_physical_plan().await.unwrap();
let baseline =
datafusion::physical_plan::collect(baseline_plan, ctx.task_ctx()).await.unwrap();
let converted =
datafusion::physical_plan::collect(optimized, ctx.task_ctx()).await.unwrap();
let cells = |bs: &[arrow::record_batch::RecordBatch]| -> Vec<String> {
let mut out = Vec::new();
for b in bs {
for row in 0..b.num_rows() {
let cols: Vec<String> = (0..b.num_columns())
.map(|c| {
arrow::util::display::array_value_to_string(b.column(c), row).unwrap()
})
.collect();
out.push(cols.join("|"));
}
}
out.sort();
out
};
let expected = cells(&baseline);
assert_eq!(expected.len(), 100, "fixture should produce one group per key");
assert_eq!(cells(&converted), expected, "grace changed the answer");
}
#[tokio::test]
async fn a_small_build_side_keeps_its_hash_join() {
let ctx = partitioned_join_ctx();
let plan = joined_plan(&ctx).await;
let rule = SpillableJoinSelection::with_threshold(Some(u64::MAX));
let optimized = rule.optimize(Arc::clone(&plan), &ConfigOptions::default()).unwrap();
assert!(contains(&optimized, "HashJoinExec"), "under-threshold joins stay hash");
assert!(!contains(&optimized, "SortMergeJoin"));
}
#[tokio::test]
async fn a_stacked_join_on_the_same_key_does_not_re_sort() {
let ctx = partitioned_join_ctx();
ctx.sql("CREATE TABLE l1 AS SELECT v % 1000 AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
.await.unwrap().collect().await.unwrap();
ctx.sql("CREATE TABLE l2 AS SELECT v % 700 AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
.await.unwrap().collect().await.unwrap();
ctx.sql("CREATE TABLE l3 AS SELECT v % 300 AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
.await.unwrap().collect().await.unwrap();
let sql = "SELECT l1.k FROM l1 \
WHERE EXISTS (SELECT 1 FROM l2 WHERE l2.k = l1.k) \
AND NOT EXISTS (SELECT 1 FROM l3 WHERE l3.k = l1.k)";
let plan = ctx.sql(sql).await.unwrap().create_physical_plan().await.unwrap();
let joins = |plan: &Arc<dyn ExecutionPlan>| -> usize {
datafusion::physical_plan::displayable(plan.as_ref())
.indent(true)
.to_string()
.matches("HashJoinExec")
.count()
};
assert_eq!(
joins(&plan),
2,
"precondition: both subqueries must plan as hash joins:\n{}",
datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
);
assert!(
contains(&plan, "mode=Partitioned"),
"precondition: partitioned, or the rule declines and this tests nothing:\n{}",
datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
);
let optimized = SpillableJoinSelection::with_threshold(Some(1))
.optimize(Arc::clone(&plan), &ConfigOptions::default())
.unwrap();
let rendered = datafusion::physical_plan::displayable(optimized.as_ref())
.indent(true)
.to_string();
assert_eq!(
rendered.matches("SortMergeJoin").count(),
2,
"precondition: both joins convert, or there is no stacking to test:\n{rendered}"
);
assert_eq!(
rendered.matches("SortExec").count(),
3,
"the upper join must reuse the lower join's ordering:\n{rendered}"
);
let baseline_plan = ctx.sql(sql).await.unwrap().create_physical_plan().await.unwrap();
let baseline =
datafusion::physical_plan::collect(baseline_plan, ctx.task_ctx()).await.unwrap();
let converted =
datafusion::physical_plan::collect(optimized, ctx.task_ctx()).await.unwrap();
let rows = |bs: &[arrow::record_batch::RecordBatch]| -> usize {
bs.iter().map(|b| b.num_rows()).sum()
};
assert!(rows(&baseline) > 0, "fixture must produce rows");
assert_eq!(rows(&converted), rows(&baseline), "skipping the sort changed the answer");
}
#[tokio::test]
async fn no_cap_leaves_the_plan_alone() {
let ctx = partitioned_join_ctx();
let plan = joined_plan(&ctx).await;
let rule = SpillableJoinSelection::with_threshold(None);
let optimized = rule.optimize(Arc::clone(&plan), &ConfigOptions::default()).unwrap();
assert!(contains(&optimized, "HashJoinExec"));
assert!(!contains(&optimized, "SortMergeJoin"));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod row_count_fallback_tests {
use super::*;
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::{ColumnStatistics, Statistics};
fn schema(fields: Vec<Field>) -> Schema {
Schema::new(fields)
}
fn stats_with(num_rows: Precision<usize>, columns: usize) -> Statistics {
Statistics {
num_rows,
total_byte_size: Precision::Absent,
column_statistics: vec![ColumnStatistics::new_unknown(); columns],
}
}
#[test]
fn absent_rows_and_bytes_yields_no_estimate() {
let s = schema(vec![Field::new("k", DataType::Int64, false)]);
assert_eq!(
estimated_build_bytes_from_rows(&stats_with(Precision::Absent, 1), &s),
None
);
}
#[test]
fn a_row_count_gives_an_estimate_when_byte_size_is_absent() {
let s = schema(vec![Field::new("k", DataType::Int64, false)]);
assert_eq!(
estimated_build_bytes_from_rows(&stats_with(Precision::Exact(1_000_000), 1), &s),
Some(8_000_000)
);
}
#[test]
fn inexact_row_counts_count_too() {
let s = schema(vec![Field::new("k", DataType::Int64, false)]);
assert_eq!(
estimated_build_bytes_from_rows(&stats_with(Precision::Inexact(1_000), 1), &s),
Some(8_000)
);
}
#[test]
fn varlen_columns_get_a_modest_assumed_width() {
let s = schema(vec![
Field::new("k", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
]);
let want = 100 * (8 + crate::join_estimates::ASSUMED_VARLEN_COLUMN_BYTES as u64);
assert_eq!(
estimated_build_bytes_from_rows(&stats_with(Precision::Exact(100), 2), &s),
Some(want)
);
}
#[test]
fn a_zero_row_build_side_estimates_zero_not_unknown() {
let s = schema(vec![Field::new("k", DataType::Int64, false)]);
assert_eq!(
estimated_build_bytes_from_rows(&stats_with(Precision::Exact(0), 1), &s),
Some(0)
);
}
#[test]
fn a_huge_row_count_does_not_overflow_into_a_small_estimate() {
let s = schema(vec![Field::new("k", DataType::Int64, false)]);
let est = estimated_build_bytes_from_rows(&stats_with(Precision::Exact(usize::MAX), 1), &s)
.expect("a known row count always yields an estimate");
assert!(est > u64::from(u32::MAX), "estimate collapsed to {est}");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod collect_left_tests {
use super::*;
use datafusion::physical_plan::displayable;
use datafusion::prelude::{SessionConfig, SessionContext};
fn single_partition_ctx() -> SessionContext {
SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1))
}
fn shows(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
displayable(plan.as_ref()).indent(true).to_string().contains(name)
}
async fn one_partition_join_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
ctx.sql("CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20), (3, 30)")
.await
.unwrap()
.collect()
.await
.unwrap();
ctx.sql("CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200)")
.await
.unwrap()
.collect()
.await
.unwrap();
ctx.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
.await
.unwrap()
.create_physical_plan()
.await
.unwrap()
}
#[tokio::test]
async fn a_single_partition_plan_really_does_produce_collect_left() {
let ctx = single_partition_ctx();
let plan = one_partition_join_plan(&ctx).await;
assert!(
shows(&plan, "CollectLeft"),
"expected CollectLeft at target_partitions=1, got:\n{}",
displayable(plan.as_ref()).indent(true)
);
}
#[tokio::test]
async fn a_large_collect_left_join_becomes_sort_merge() {
let ctx = single_partition_ctx();
let plan = one_partition_join_plan(&ctx).await;
let rule = SpillableJoinSelection::with_threshold(Some(1));
let out = rule.optimize(plan, ctx.copied_config().options()).unwrap();
assert!(
shows(&out, "SortMergeJoin"),
"CollectLeft join was not converted:\n{}",
displayable(out.as_ref()).indent(true)
);
}
#[tokio::test]
async fn a_small_collect_left_join_is_left_alone() {
let ctx = single_partition_ctx();
let plan = one_partition_join_plan(&ctx).await;
let rule = SpillableJoinSelection::with_threshold(Some(64 * 1024 * 1024));
let out = rule.optimize(plan, ctx.copied_config().options()).unwrap();
assert!(shows(&out, "HashJoin"), "small join should stay a hash join");
assert!(!shows(&out, "SortMergeJoin"));
}
#[tokio::test]
async fn the_converted_plan_returns_the_same_rows() {
use datafusion::physical_plan::collect;
let ctx = single_partition_ctx();
let plan = one_partition_join_plan(&ctx).await;
let task_ctx = ctx.task_ctx();
let hash_rows = collect(Arc::clone(&plan), Arc::clone(&task_ctx)).await.unwrap();
let converted = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(shows(&converted, "SortMergeJoin"));
let smj_rows = collect(converted, task_ctx).await.unwrap();
let total = |b: &[arrow::array::RecordBatch]| -> usize {
b.iter().map(arrow::array::RecordBatch::num_rows).sum()
};
assert_eq!(total(&hash_rows), total(&smj_rows), "row count changed");
assert_eq!(total(&smj_rows), 2, "expected the two matching keys");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod degenerate_broadcast_tests {
use super::*;
use datafusion::physical_plan::{collect, displayable};
use datafusion::prelude::{SessionConfig, SessionContext};
fn shows(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
displayable(plan.as_ref()).indent(true).to_string().contains(name)
}
fn multi_partition_ctx() -> SessionContext {
SessionContext::new_with_config(SessionConfig::new().with_target_partitions(4))
}
async fn broadcast_join_over_split_probe(
ctx: &SessionContext,
degenerate: bool,
) -> Arc<dyn ExecutionPlan> {
use arrow::array::Int32Array;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use datafusion::datasource::MemTable;
let build_schema = Arc::new(Schema::new(vec![
Field::new("k", DataType::Int32, false),
Field::new("v", DataType::Int32, false),
]));
if degenerate {
let empty = MemTable::try_new(Arc::clone(&build_schema), vec![vec![]]).unwrap();
ctx.register_table("l", Arc::new(empty)).unwrap();
} else {
let rows = RecordBatch::try_new(
Arc::clone(&build_schema),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(Int32Array::from(vec![10, 20, 30])),
],
)
.unwrap();
let table = MemTable::try_new(Arc::clone(&build_schema), vec![vec![rows]]).unwrap();
ctx.register_table("l", Arc::new(table)).unwrap();
}
let schema = Arc::new(Schema::new(vec![
Field::new("k", DataType::Int32, false),
Field::new("w", DataType::Int32, false),
]));
let partition = |k: i32, w: i32| {
vec![
RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![k])),
Arc::new(Int32Array::from(vec![w])),
],
)
.unwrap(),
]
};
let split = MemTable::try_new(
Arc::clone(&schema),
vec![partition(1, 100), partition(2, 200)],
)
.unwrap();
ctx.register_table("r", Arc::new(split)).unwrap();
ctx.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
.await
.unwrap()
.create_physical_plan()
.await
.unwrap()
}
#[tokio::test]
async fn the_premise_holds_a_collect_left_join_over_a_split_probe() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, true).await;
assert!(shows(&plan, "CollectLeft"), "fixture is not a broadcast join");
assert_eq!(
plan.children()[0].output_partitioning().partition_count(),
1,
"build side should be un-split"
);
assert!(
plan.children()[1].output_partitioning().partition_count() > 1,
"probe side must be split — that is the case the old rule refused"
);
}
#[tokio::test]
async fn an_oversized_broadcast_join_is_repartitioned_so_it_can_spill() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, true).await;
let out = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(
shows(&out, "SortMergeJoin"),
"broadcast join over a split probe side was left un-spillable:\n{}",
displayable(out.as_ref()).indent(true)
);
assert!(
shows(&out, "RepartitionExec"),
"sort-merge needs both sides hash-partitioned on the join keys:\n{}",
displayable(out.as_ref()).indent(true)
);
}
#[tokio::test]
async fn a_rescued_broadcast_join_is_offered_to_grace() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, true).await;
let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(
shows(&out, "GraceHashJoin"),
"grace declined a join whose sides this rule then repartitioned itself:\n{}",
displayable(out.as_ref()).indent(true)
);
assert!(
!shows(&out, "SortMergeJoin"),
"grace applies here, so sort-merge should not have been reached:\n{}",
displayable(out.as_ref()).indent(true)
);
}
#[tokio::test]
async fn the_rescued_grace_plan_executes_on_every_partition() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, true).await;
let task_ctx = ctx.task_ctx();
let before = all_rows(Arc::clone(&plan), Arc::clone(&task_ctx)).await;
let converted = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(shows(&converted, "GraceHashJoin"), "precondition: grace must have applied");
let after = all_rows(converted, task_ctx).await;
assert_eq!(before, after, "row count changed across the re-plan");
assert_eq!(after, 0, "an empty build side joins to nothing");
}
async fn all_rows(
plan: Arc<dyn ExecutionPlan>,
task_ctx: Arc<datafusion::execution::TaskContext>,
) -> usize {
use datafusion::physical_plan::coalesce_partitions::CoalescePartitionsExec;
let merged = Arc::new(CoalescePartitionsExec::new(plan));
collect(merged, task_ctx)
.await
.unwrap()
.iter()
.map(arrow::array::RecordBatch::num_rows)
.sum()
}
#[tokio::test]
async fn repartitioning_does_not_change_the_answer() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, true).await;
let task_ctx = ctx.task_ctx();
let before = all_rows(Arc::clone(&plan), Arc::clone(&task_ctx)).await;
let converted = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options())
.unwrap();
let after = all_rows(converted, task_ctx).await;
assert_eq!(before, after, "row count changed across the re-plan");
assert_eq!(after, 0, "an empty build side joins to nothing");
}
#[tokio::test]
async fn a_small_broadcast_join_keeps_its_hash_join() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, false).await;
let out = SpillableJoinSelection::with_threshold(Some(1 << 30))
.optimize(Arc::clone(&plan), ctx.copied_config().options())
.unwrap();
assert!(
!shows(&out, "SortMergeJoin"),
"a build side well under the threshold was converted anyway:\n{}",
displayable(out.as_ref()).indent(true)
);
}
#[tokio::test]
async fn an_honest_broadcast_join_is_never_repartitioned() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, false).await;
assert!(shows(&plan, "CollectLeft"), "premise: an honest broadcast join");
let out = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(
!shows(&out, "RepartitionExec"),
"an honestly-sized broadcast join gained an exchange — this is the \
q21 distributed regression (11 stages -> 13):\n{}",
displayable(out.as_ref()).indent(true)
);
assert!(
shows(&out, "CollectLeft"),
"the join should have been left alone entirely:\n{}",
displayable(out.as_ref()).indent(true)
);
}
#[tokio::test]
async fn the_coordinator_never_repartitions_even_a_degenerate_broadcast() {
let ctx = multi_partition_ctx();
let plan = broadcast_join_over_split_probe(&ctx, true).await;
let out = SpillableJoinSelection::with_threshold(Some(1))
.without_broadcast_rescue()
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(
!shows(&out, "RepartitionExec"),
"a plan bound for stage-cutting gained an exchange:\n{}",
displayable(out.as_ref()).indent(true)
);
}
#[tokio::test]
async fn a_single_partition_broadcast_join_gains_no_exchange() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
ctx.sql("CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20), (3, 30)")
.await
.unwrap()
.collect()
.await
.unwrap();
ctx.sql("CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200)")
.await
.unwrap()
.collect()
.await
.unwrap();
let plan = ctx
.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
.await
.unwrap()
.create_physical_plan()
.await
.unwrap();
assert!(shows(&plan, "CollectLeft"), "premise: one partition broadcasts");
let out = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(shows(&out, "SortMergeJoin"));
assert!(
!shows(&out, "RepartitionExec"),
"a one-partition plan gained an exchange it cannot use:\n{}",
displayable(out.as_ref()).indent(true)
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod never_fails_the_query_tests {
use super::*;
use datafusion::physical_plan::displayable;
use datafusion::prelude::{SessionConfig, SessionContext};
async fn joined_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
ctx.sql("CREATE TABLE a(k INT, v INT) AS VALUES (1, 1), (2, 2)")
.await
.unwrap()
.collect()
.await
.unwrap();
ctx.sql("CREATE TABLE b(k INT, w INT) AS VALUES (1, 9)")
.await
.unwrap()
.collect()
.await
.unwrap();
ctx.sql("CREATE TABLE c(k INT, z INT) AS VALUES (1, 5)")
.await
.unwrap()
.collect()
.await
.unwrap();
ctx.sql("SELECT a.v, b.w, c.z FROM a JOIN b ON a.k = b.k JOIN c ON a.k = c.k")
.await
.unwrap()
.create_physical_plan()
.await
.unwrap()
}
#[tokio::test]
async fn stacked_joins_never_make_the_rule_return_an_error() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let plan = joined_plan(&ctx).await;
let out = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options());
assert!(
out.is_ok(),
"the rule must never fail a plan; got {:?}",
out.err()
);
}
#[tokio::test]
async fn a_plan_the_rule_declines_is_returned_unchanged_and_still_runs() {
use datafusion::physical_plan::collect;
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let plan = joined_plan(&ctx).await;
let before = displayable(plan.as_ref()).indent(true).to_string();
let task_ctx = ctx.task_ctx();
let out = SpillableJoinSelection::with_threshold(Some(1 << 40))
.optimize(Arc::clone(&plan), ctx.copied_config().options())
.unwrap();
assert_eq!(
before,
displayable(out.as_ref()).indent(true).to_string(),
"declining must leave the plan untouched"
);
let rows = collect(out, task_ctx).await.unwrap();
let total: usize = rows.iter().map(arrow::array::RecordBatch::num_rows).sum();
assert_eq!(total, 1, "the declined plan must still produce the join result");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod budget_tests {
use super::*;
fn facts(plan: &Arc<dyn ExecutionPlan>) -> Vec<JoinFacts> {
let mut out = Vec::new();
collect_join_facts(plan, 1, true, &mut out);
out
}
fn sizes_of(facts: &[JoinFacts]) -> Vec<u64> {
facts.iter().map(|f| f.retained_bytes()).collect()
}
fn retained(facts: &[JoinFacts], decisions: &[bool]) -> u64 {
facts
.iter()
.zip(decisions)
.filter(|(_, convert)| !**convert)
.map(|(f, _)| f.retained_bytes())
.fold(0, u64::saturating_add)
}
#[test]
fn joins_that_each_fit_but_together_do_not_are_converted() {
let plan = plan_with_build_sizes(&[200; 8]);
let facts = facts(&plan);
let sizes = sizes_of(&facts);
let largest = *sizes.iter().max().expect("fixture has joins");
let total: u64 = sizes.iter().copied().fold(0, u64::saturating_add);
let budget = largest;
assert!(
total > budget,
"fixture must create aggregate pressure: total {total} vs budget {budget}"
);
let decisions = SpillableJoinSelection::conversion_decisions(&facts, budget);
assert!(
retained(&facts, &decisions) <= budget,
"the un-converted sum {} exceeds the {budget} budget (sizes {sizes:?})",
retained(&facts, &decisions)
);
}
#[test]
fn without_pressure_nothing_is_chosen() {
let plan = plan_with_build_sizes(&[50, 60]);
let facts = facts(&plan);
let total: u64 = sizes_of(&facts).iter().copied().fold(0, u64::saturating_add);
let decisions = SpillableJoinSelection::conversion_decisions(&facts, total + 1);
assert!(
decisions.iter().all(|convert| !convert),
"a total that fits the budget must convert nothing: {decisions:?}"
);
}
#[test]
fn a_single_oversized_join_still_converts() {
let plan = plan_with_build_sizes(&[900]);
let facts = facts(&plan);
let largest = *sizes_of(&facts).iter().max().expect("fixture has a join");
let decisions = SpillableJoinSelection::conversion_decisions(&facts, largest - 1);
assert_eq!(decisions, vec![true], "an over-budget join must convert");
}
#[test]
fn unmeasurable_joins_still_create_aggregate_pressure() {
let facts = vec![
JoinFacts { bytes: None, convertible: true },
JoinFacts { bytes: None, convertible: true },
JoinFacts { bytes: None, convertible: true },
JoinFacts { bytes: Some(900), convertible: true },
];
let threshold = 1000;
let decisions = SpillableJoinSelection::conversion_decisions(&facts, threshold);
assert!(
decisions.iter().any(|convert| *convert),
"unmeasurable joins must count as pressure, or the budget is blind \
to exactly the joins it exists to bound: {decisions:?}"
);
}
#[test]
fn measurable_plans_are_unaffected_by_the_unknown_pressure_term() {
let facts = vec![
JoinFacts { bytes: Some(50), convertible: true },
JoinFacts { bytes: Some(60), convertible: true },
];
assert_eq!(
super::unknown_build_pressure(&facts, 1000),
0,
"no unknowns means no assumed pressure"
);
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1000);
assert!(
decisions.iter().all(|convert| !convert),
"a fully measurable plan under budget must convert nothing: {decisions:?}"
);
}
#[test]
fn a_single_unknown_among_many_does_not_force_conversion() {
let mut facts: Vec<JoinFacts> = (0..9)
.map(|_| JoinFacts { bytes: Some(1), convertible: true })
.collect();
facts.push(JoinFacts { bytes: None, convertible: true });
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1000);
assert!(
decisions.iter().all(|convert| !convert),
"one unknown among ten small joins is not aggregate pressure: {decisions:?}"
);
}
#[test]
fn an_all_unknown_plan_is_beyond_the_budgets_reach() {
let facts: Vec<JoinFacts> = (0..4)
.map(|_| JoinFacts { bytes: None, convertible: true })
.collect();
assert_eq!(
super::unknown_build_pressure(&facts, 1000),
1000,
"four unknowns each charged threshold/4 sum to the whole threshold"
);
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1000);
assert!(
decisions.iter().all(|convert| !convert),
"an all-unknown plan has no candidate to convert: {decisions:?}"
);
}
#[test]
fn a_plan_without_joins_is_left_alone() {
let plan = plan_with_build_sizes(&[]);
let facts = facts(&plan);
assert!(facts.is_empty());
assert!(SpillableJoinSelection::conversion_decisions(&facts, 250).is_empty());
}
#[test]
fn equal_sized_joins_are_decided_individually() {
let plan = plan_with_build_sizes(&[100, 100, 100]);
let facts = facts(&plan);
let sizes = sizes_of(&facts);
let one = sizes.first().copied().expect("fixture has joins");
let budget = one * 2;
let decisions = SpillableJoinSelection::conversion_decisions(&facts, budget);
assert_eq!(
decisions.iter().filter(|convert| **convert).count(),
1,
"exactly one of three equal joins should convert, not all of them: \
{decisions:?} (sizes {sizes:?}, budget {budget})"
);
assert!(
retained(&facts, &decisions) <= budget,
"the retained set must still fit the budget"
);
}
#[test]
fn unconvertible_joins_are_charged_to_the_budget_first() {
let big_unconvertible = JoinFacts { bytes: Some(100), convertible: false };
let small_candidate = JoinFacts { bytes: Some(30), convertible: true };
let facts = [big_unconvertible, small_candidate];
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 120);
assert_eq!(
decisions,
vec![false, true],
"the unconvertible join stays (it must), and the candidate converts \
because the budget it draws on is what is left after it"
);
}
#[test]
fn unmeasurable_joins_are_never_chosen() {
let facts = [
JoinFacts { bytes: None, convertible: true },
JoinFacts { bytes: Some(500), convertible: true },
];
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 10);
assert_eq!(decisions, vec![false, true]);
}
pub(super) fn plan_with_build_sizes(sizes: &[u64]) -> Arc<dyn ExecutionPlan> {
let mut plan: Arc<dyn ExecutionPlan> = sized_source(1);
for size in sizes {
plan = Arc::new(
HashJoinExec::try_new(
sized_source(*size),
Arc::clone(&plan),
vec![(
Arc::new(datafusion::physical_expr::expressions::Column::new("k", 0)),
Arc::new(datafusion::physical_expr::expressions::Column::new("k", 0)),
)],
None,
&datafusion::common::JoinType::Inner,
None,
PartitionMode::CollectLeft,
datafusion::common::NullEquality::NullEqualsNothing,
false,
)
.expect("hash join"),
);
}
plan
}
pub(super) fn sized_source(bytes: u64) -> Arc<dyn ExecutionPlan> {
use arrow::array::Int32Array;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int32, false)]));
let rows = usize::try_from(bytes / 4).unwrap_or(1).max(1);
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![Arc::new(Int32Array::from(vec![0; rows]))],
)
.expect("batch");
datafusion::datasource::memory::MemorySourceConfig::try_new_exec(
&[vec![batch]],
schema,
None,
)
.expect("memory exec")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod projection_tests {
use super::*;
use datafusion::physical_plan::{collect, displayable};
use datafusion::prelude::{SessionConfig, SessionContext};
async fn stacked_projected_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
for ddl in [
"CREATE TABLE c(c_custkey INT, c_name VARCHAR) AS VALUES (1, 'a'), (2, 'b')",
"CREATE TABLE o(o_orderkey INT, o_custkey INT, o_total INT) AS VALUES (10, 1, 5)",
"CREATE TABLE l(l_orderkey INT, l_qty INT) AS VALUES (10, 3)",
] {
ctx.sql(ddl).await.unwrap().collect().await.unwrap();
}
ctx.sql(
"SELECT c.c_name, l.l_qty \
FROM c JOIN o ON c.c_custkey = o.o_custkey \
JOIN l ON o.o_orderkey = l.l_orderkey",
)
.await
.unwrap()
.create_physical_plan()
.await
.unwrap()
}
fn sort_merge_join_count(plan: &Arc<dyn ExecutionPlan>) -> usize {
let any = plan.as_ref() as &dyn std::any::Any;
let here = usize::from(any.downcast_ref::<SortMergeJoinExec>().is_some());
here + plan
.children()
.iter()
.map(|c| sort_merge_join_count(c))
.sum::<usize>()
}
fn has_projected_hash_join(plan: &Arc<dyn ExecutionPlan>) -> bool {
let any = plan.as_ref() as &dyn std::any::Any;
any.downcast_ref::<HashJoinExec>()
.is_some_and(HashJoinExec::contains_projection)
|| plan.children().iter().any(|c| has_projected_hash_join(c))
}
fn cells(batches: &[arrow::array::RecordBatch]) -> Vec<String> {
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
}
#[tokio::test]
async fn converting_a_projected_join_keeps_the_output_columns() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let plan = stacked_projected_plan(&ctx).await;
let before_schema = plan.schema();
assert!(
has_projected_hash_join(&plan),
"fixture must build a hash join carrying a projection, or this tests nothing:\n{}",
displayable(plan.as_ref()).indent(true)
);
let out = SpillableJoinSelection::with_threshold(Some(1))
.optimize(Arc::clone(&plan), ctx.copied_config().options())
.expect("the rule must not fail the plan");
assert!(
sort_merge_join_count(&out) > 0,
"the rule declined, so the conversion under test never ran:\n{}",
displayable(out.as_ref()).indent(true)
);
assert_eq!(
out.schema(),
before_schema,
"conversion changed the plan's output schema:\n{}",
displayable(out.as_ref()).indent(true)
);
}
#[tokio::test]
async fn the_converted_projected_plan_returns_the_same_values() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let plan = stacked_projected_plan(&ctx).await;
let task_ctx = ctx.task_ctx();
assert!(has_projected_hash_join(&plan), "fixture must project");
let before = collect(Arc::clone(&plan), Arc::clone(&task_ctx)).await.unwrap();
let out = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert!(
sort_merge_join_count(&out) > 0,
"the rule declined, so the conversion under test never ran"
);
let after = collect(out, task_ctx).await.unwrap();
assert_eq!(cells(&before), cells(&after), "converted plan changed the data");
assert_eq!(cells(&after), vec![String::from("a|3")], "expected the single matching row");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod grace_tests {
use super::*;
use crate::grace_hash_join::GraceHashJoinExec;
use datafusion::physical_plan::{collect, displayable};
use datafusion::prelude::{SessionConfig, SessionContext};
async fn joined_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
ctx.sql("CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20), (3, 30)")
.await.unwrap().collect().await.unwrap();
ctx.sql("CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200), (2, 201)")
.await.unwrap().collect().await.unwrap();
ctx.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
.await.unwrap().create_physical_plan().await.unwrap()
}
fn grace_joins(plan: &Arc<dyn ExecutionPlan>) -> usize {
let any = plan.as_ref() as &dyn std::any::Any;
usize::from(any.downcast_ref::<GraceHashJoinExec>().is_some())
+ plan.children().iter().map(|c| grace_joins(c)).sum::<usize>()
}
#[tokio::test]
async fn a_partitioned_grace_join_buckets_per_task_not_per_relation() {
let mut config = SessionConfig::new().with_target_partitions(4);
config.options_mut().optimizer.hash_join_single_partition_threshold = 0;
config.options_mut().optimizer.hash_join_single_partition_threshold_rows = 0;
let ctx = SessionContext::new_with_config(config);
ctx.sql("CREATE TABLE big AS SELECT v % 1000 AS k, v AS payload FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
.await.unwrap().collect().await.unwrap();
ctx.sql("CREATE TABLE small AS SELECT v AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 100)) AS u(v)")
.await.unwrap().collect().await.unwrap();
let plan = ctx
.sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
.await.unwrap().create_physical_plan().await.unwrap();
assert!(
displayable(plan.as_ref()).indent(true).to_string().contains("mode=Partitioned"),
"precondition: a partitioned join, or per-task and per-relation agree"
);
let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
.optimize(plan, ctx.copied_config().options())
.unwrap();
fn grace_of(plan: &Arc<dyn ExecutionPlan>) -> Option<&GraceHashJoinExec> {
let any = plan.as_ref() as &dyn std::any::Any;
any.downcast_ref::<GraceHashJoinExec>()
.or_else(|| plan.children().iter().find_map(|c| grace_of(c)))
}
let grace = grace_of(&out).expect("grace join");
let partitions = grace.children()[0].output_partitioning().partition_count();
assert!(partitions > 1, "precondition: more than one partition to divide by");
let whole_relation =
crate::grace_hash_join::bucket_count(build_bytes_of(&out).unwrap_or(0), 1);
let per_task = crate::grace_hash_join::bucket_count(
build_bytes_of(&out).unwrap_or(0) / partitions as u64,
1,
);
if whole_relation != per_task {
assert_eq!(
grace.buckets(),
per_task,
"grace bucketed for the whole relation ({whole_relation}) instead of \
for one task ({per_task}) across {partitions} partitions"
);
}
}
fn build_bytes_of(plan: &Arc<dyn ExecutionPlan>) -> Option<u64> {
fn walk(plan: &Arc<dyn ExecutionPlan>) -> Option<u64> {
let any = plan.as_ref() as &dyn std::any::Any;
if let Some(grace) = any.downcast_ref::<GraceHashJoinExec>() {
let build = &grace.children()[0];
let stats = build.partition_statistics(None).ok()?;
return match stats.total_byte_size {
Precision::Exact(b) | Precision::Inexact(b) => u64::try_from(b).ok(),
Precision::Absent => {
estimated_build_bytes_from_rows(&stats, &build.schema())
}
};
}
plan.children().iter().find_map(|c| walk(c))
}
walk(plan)
}
#[tokio::test]
async fn an_oversized_join_becomes_a_grace_hash_join_when_enabled() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let plan = joined_plan(&ctx).await;
let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert_eq!(
grace_joins(&out),
1,
"expected a grace hash join:\n{}",
displayable(out.as_ref()).indent(true)
);
assert!(
!displayable(out.as_ref()).indent(true).to_string().contains("SortMergeJoin"),
"grace should have been preferred over sort-merge"
);
}
#[tokio::test]
async fn with_the_flag_off_the_sort_merge_conversion_is_unchanged() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let plan = joined_plan(&ctx).await;
let out = SpillableJoinSelection::with_threshold(Some(1))
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert_eq!(grace_joins(&out), 0, "the flag is off; no grace join should appear");
assert!(
displayable(out.as_ref()).indent(true).to_string().contains("SortMergeJoin"),
"the sort-merge path must still work"
);
}
#[tokio::test]
async fn the_grace_plan_returns_the_same_rows() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
let plan = joined_plan(&ctx).await;
let task_ctx = ctx.task_ctx();
let baseline = collect(Arc::clone(&plan), Arc::clone(&task_ctx)).await.unwrap();
let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
.optimize(plan, ctx.copied_config().options())
.unwrap();
assert_eq!(grace_joins(&out), 1, "the rule declined; this proved nothing");
let converted = collect(out, task_ctx).await.unwrap();
let cells = |bs: &[arrow::array::RecordBatch]| -> Vec<String> {
let mut rows: Vec<String> = bs
.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
};
assert_eq!(cells(&baseline), cells(&converted));
assert_eq!(cells(&converted), vec!["10|100", "20|200", "20|201"]);
}
#[tokio::test]
async fn a_refused_shape_falls_back_instead_of_failing() {
let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(4));
let plan = joined_plan(&ctx).await;
let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
.optimize(Arc::clone(&plan), ctx.copied_config().options());
assert!(out.is_ok(), "a refusal must never fail the plan: {:?}", out.err());
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod join_filter_order_tests {
use super::*;
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::JoinSide;
use datafusion::physical_expr::expressions::{BinaryExpr, Column};
use datafusion::physical_plan::joins::utils::ColumnIndex;
fn right_first() -> JoinFilter {
let schema = Arc::new(Schema::new(vec![
Field::new("q", DataType::Decimal128(15, 2), true),
Field::new("b", DataType::Utf8, true),
]));
let expression = Arc::new(BinaryExpr::new(
Arc::new(Column::new("q", 0)),
datafusion::logical_expr::Operator::Lt,
Arc::new(Column::new("b", 1)),
));
JoinFilter::new(
expression,
vec![
ColumnIndex { index: 0, side: JoinSide::Right },
ColumnIndex { index: 0, side: JoinSide::Left },
],
schema,
)
}
#[test]
fn a_right_first_filter_is_reordered_to_left_first() {
let out = left_first_filter(&right_first()).expect("normalisable");
assert_eq!(
out.column_indices()
.iter()
.map(|c| c.side)
.collect::<Vec<_>>(),
vec![JoinSide::Left, JoinSide::Right],
);
assert_eq!(
out.schema()
.fields()
.iter()
.map(|f| f.name().clone())
.collect::<Vec<_>>(),
vec!["b".to_string(), "q".to_string()],
"the intermediate schema must follow the new column order"
);
}
#[test]
fn the_expression_is_repointed_at_the_new_positions() {
let out = left_first_filter(&right_first()).expect("normalisable");
let rendered = format!("{}", out.expression());
assert!(
rendered.contains("q@1") && rendered.contains("b@0"),
"expression still points at the old positions: {rendered}"
);
}
#[test]
fn an_already_left_first_filter_is_untouched() {
let schema = Arc::new(Schema::new(vec![
Field::new("b", DataType::Utf8, true),
Field::new("q", DataType::Decimal128(15, 2), true),
]));
let expression = Arc::new(BinaryExpr::new(
Arc::new(Column::new("b", 0)),
datafusion::logical_expr::Operator::Lt,
Arc::new(Column::new("q", 1)),
));
let filter = JoinFilter::new(
expression,
vec![
ColumnIndex { index: 0, side: JoinSide::Left },
ColumnIndex { index: 0, side: JoinSide::Right },
],
schema,
);
let out = left_first_filter(&filter).expect("normalisable");
assert_eq!(format!("{}", out.expression()), format!("{}", filter.expression()));
assert_eq!(out.column_indices(), filter.column_indices());
}
#[test]
fn relative_order_within_each_side_is_preserved() {
let schema = Arc::new(Schema::new(vec![
Field::new("r0", DataType::Int32, true),
Field::new("l0", DataType::Int32, true),
Field::new("r1", DataType::Int32, true),
Field::new("l1", DataType::Int32, true),
]));
let filter = JoinFilter::new(
Arc::new(Column::new("l1", 3)),
vec![
ColumnIndex { index: 7, side: JoinSide::Right },
ColumnIndex { index: 5, side: JoinSide::Left },
ColumnIndex { index: 9, side: JoinSide::Right },
ColumnIndex { index: 6, side: JoinSide::Left },
],
schema,
);
let out = left_first_filter(&filter).expect("normalisable");
assert_eq!(
out.column_indices()
.iter()
.map(|c| (c.side, c.index))
.collect::<Vec<_>>(),
vec![
(JoinSide::Left, 5),
(JoinSide::Left, 6),
(JoinSide::Right, 7),
(JoinSide::Right, 9),
],
);
assert_eq!(format!("{}", out.expression()), "l1@1");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod encodability_tests {
use super::*;
use crate::grace_hash_join::GraceHashJoinExec;
use datafusion::prelude::SessionContext;
fn grace_joins(plan: &Arc<dyn ExecutionPlan>) -> usize {
let any = plan.as_ref() as &dyn std::any::Any;
usize::from(any.downcast_ref::<GraceHashJoinExec>().is_some())
+ plan.children().iter().map(|c| grace_joins(c)).sum::<usize>()
}
#[test]
fn grace_opens_only_where_plans_are_never_encoded() {
let coordinator = SpillableJoinSelection::with_threshold(Some(1))
.with_grace_gated(false, true);
assert!(
!coordinator.grace,
"the flag opened grace on a process whose plans get encoded — a \
grace join in a stage plan runs the whole query as a SINGLE TASK"
);
let one_shot_cli = SpillableJoinSelection::with_threshold(Some(1))
.with_grace_gated(true, true);
assert!(
one_shot_cli.grace,
"grace stayed shut in a process that never encodes a plan, which \
is the whole point of the gate"
);
let flag_off = SpillableJoinSelection::with_threshold(Some(1))
.with_grace_gated(true, false);
assert!(!flag_off.grace, "the gate turned grace on by itself");
}
#[tokio::test]
async fn the_staging_planner_never_emits_an_unencodable_grace_join() {
let rule = SpillableJoinSelection::with_threshold(Some(1));
let ctx = SessionContext::new_with_config(
datafusion::prelude::SessionConfig::new().with_target_partitions(1),
);
for ddl in [
"CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20)",
"CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200)",
] {
ctx.sql(ddl).await.unwrap().collect().await.unwrap();
}
let plan = ctx
.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
.await
.unwrap()
.create_physical_plan()
.await
.unwrap();
let out = rule.optimize(plan, ctx.copied_config().options()).unwrap();
assert_eq!(
grace_joins(&out),
0,
"the staging planner produced a grace join, which cannot be encoded:\n{}",
datafusion::physical_plan::displayable(out.as_ref()).indent(true)
);
assert!(
datafusion::physical_plan::displayable(out.as_ref())
.indent(true)
.to_string()
.contains("SortMergeJoin"),
"the rule declined entirely, so encodability was never at stake"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod budget_never_loosens_tests {
use super::*;
use super::budget_tests::plan_with_build_sizes;
fn facts_of(plan: &Arc<dyn ExecutionPlan>) -> Vec<JoinFacts> {
let mut out = Vec::new();
collect_join_facts(plan, 1, true, &mut out);
out
}
fn retained(facts: &[JoinFacts], decisions: &[bool]) -> u64 {
facts
.iter()
.zip(decisions)
.filter(|(_, convert)| !**convert)
.map(|(f, _)| f.retained_bytes())
.fold(0, u64::saturating_add)
}
#[test]
fn everything_over_the_configured_threshold_still_converts() {
for configured in [1_u64, 1_000, 250_000_000] {
let plan = plan_with_build_sizes(&[900, 400, 300]);
let facts = facts_of(&plan);
let decisions = SpillableJoinSelection::conversion_decisions(&facts, configured);
let converted = decisions.iter().filter(|convert| **convert).count();
let would_have = facts
.iter()
.filter(|f| f.retained_bytes() > configured)
.count();
assert!(
converted >= would_have,
"the budget converted {converted} joins where the plain threshold \
would have converted {would_have} (configured {configured})"
);
}
}
#[test]
fn the_joins_left_as_hash_joins_fit_the_budget() {
let plan = plan_with_build_sizes(&[200; 8]);
let facts = facts_of(&plan);
let total: u64 = facts
.iter()
.map(|f| f.retained_bytes())
.fold(0, u64::saturating_add);
let largest = facts
.iter()
.map(|f| f.retained_bytes())
.max()
.expect("fixture has joins");
let budget = largest * 2;
assert!(total > budget, "fixture must create aggregate pressure");
let decisions = SpillableJoinSelection::conversion_decisions(&facts, budget);
assert!(
retained(&facts, &decisions) <= budget,
"un-converted joins sum to {}, over the {budget} budget",
retained(&facts, &decisions)
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod degenerate_sentinel_budget_tests {
use super::*;
fn fact(bytes: Option<u64>, convertible: bool) -> JoinFacts {
JoinFacts { bytes, convertible }
}
#[test]
fn a_degenerate_estimate_does_not_zero_the_budget_for_everyone_else() {
let facts = vec![
fact(Some(DEGENERATE_BUILD_BYTES), false),
fact(Some(100), true),
fact(Some(200), true),
];
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 10_000);
assert_eq!(
decisions,
vec![false, false, false],
"a threshold that fits the measurable joins must retain them"
);
}
#[test]
fn under_pressure_the_degenerate_join_is_the_one_that_converts() {
let facts = vec![
fact(Some(DEGENERATE_BUILD_BYTES), true),
fact(Some(200), true),
];
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 250);
assert_eq!(
decisions,
vec![true, false],
"the unbounded join converts and the measurable one that fits is kept"
);
}
#[test]
fn a_degenerate_join_does_not_buy_the_others_a_free_pass() {
let facts = vec![
fact(Some(DEGENERATE_BUILD_BYTES), false),
fact(Some(900), true),
fact(Some(800), true),
];
let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1_000);
assert!(
decisions[1] || decisions[2],
"900 + 800 cannot both be retained under a 1000 budget: {decisions:?}"
);
}
}