Skip to content

Commit b3a5bf3

Browse files
committed
test: promised probe-side ordering disables the sort-merge fallback
1 parent 52b9fd5 commit b3a5bf3

3 files changed

Lines changed: 180 additions & 25 deletions

File tree

‎datafusion/core/tests/memory_limit/hash_join_sort_merge_fallback.rs‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
2121
use std::sync::Arc;
2222

23-
use arrow::array::{Int32Array, RecordBatch};
24-
use arrow::datatypes::{DataType, Field, Schema};
23+
use arrow::array::{AsArray, Int32Array, RecordBatch};
24+
use arrow::compute::concat_batches;
25+
use arrow::datatypes::{DataType, Field, Int32Type, Schema};
26+
use datafusion::physical_plan::displayable;
2527
use datafusion::prelude::*;
2628
use datafusion_common::assert_contains;
2729
use datafusion_execution::disk_manager::{DiskManagerBuilder, DiskManagerMode};
@@ -274,3 +276,50 @@ async fn semi_and_anti_joins_fall_back() {
274276
assert_eq!(capped, plain, "{sql}");
275277
}
276278
}
279+
280+
/// A join that promises its probe side's ordering keeps that promise instead
281+
/// of falling back. The planner pushes `ORDER BY r.w` below the join because a
282+
/// hash join emits inner-join rows in probe order, and nothing above it sorts
283+
/// again, so a merge's join-key order would come out wrong.
284+
#[tokio::test]
285+
async fn a_promised_probe_ordering_wins_over_the_fallback() {
286+
let sql = "SELECT l.k, r.w FROM l JOIN r ON l.k = r.k ORDER BY r.w";
287+
let ctx = context(
288+
config().set_usize("datafusion.execution.hash_join_max_build_size", 1024),
289+
RuntimeEnvBuilder::new(),
290+
);
291+
let plan = ctx
292+
.sql(sql)
293+
.await
294+
.unwrap()
295+
.create_physical_plan()
296+
.await
297+
.unwrap();
298+
// Guard the premise: the sort must sit below the join, or this would pass
299+
// without the join ever having promised anything.
300+
let shape = displayable(plan.as_ref()).indent(true).to_string();
301+
let join_at = shape.find("HashJoinExec").expect("a hash join");
302+
let sort_at = shape.find("SortExec:").expect("a sort");
303+
assert!(
304+
sort_at > join_at,
305+
"the sort should sit below the join:\n{shape}"
306+
);
307+
308+
let batches = datafusion::physical_plan::collect(Arc::clone(&plan), ctx.task_ctx())
309+
.await
310+
.unwrap();
311+
let output = concat_batches(&plan.schema(), &batches).unwrap();
312+
let w = output
313+
.column_by_name("w")
314+
.unwrap()
315+
.as_primitive::<Int32Type>();
316+
assert!(
317+
w.values().is_sorted(),
318+
"the output must come out ordered by w"
319+
);
320+
assert_eq!(
321+
plan_metric_sum(plan.as_ref(), "sort_merge_fallback_count"),
322+
0,
323+
"the join promised the probe order, so it must not have fallen back"
324+
);
325+
}

‎datafusion/physical-plan/src/joins/hash_join/exec.rs‎

Lines changed: 121 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9396,14 +9396,15 @@ mod tests {
93969396

93979397
/// Two sides of `batches` batches each, with duplicate keys, keys that
93989398
/// only exist on one side, and a non-key column to filter on.
9399-
fn sort_merge_fallback_inputs(
9399+
fn sort_merge_fallback_batches(
94009400
batches: usize,
9401-
) -> (Arc<dyn ExecutionPlan>, Arc<dyn ExecutionPlan>) {
9401+
) -> (Vec<RecordBatch>, Vec<RecordBatch>) {
94029402
let rows_per_batch = 16;
94039403
let side = |modulus: i32, offset: i32, a: &str, b: &str, c: &str| {
9404-
let batches: Vec<RecordBatch> = (0..batches as i32)
9404+
(0..batches as i32)
94059405
.map(|batch| {
94069406
let start = batch * rows_per_batch;
9407+
// ids ascend within and across batches
94079408
let ids: Vec<i32> = (start..start + rows_per_batch).collect();
94089409
// keys repeat within and across batches, and each side
94099410
// has keys the other side lacks
@@ -9412,13 +9413,22 @@ mod tests {
94129413
let values: Vec<i32> = ids.iter().map(|id| (id * 13) % 17).collect();
94139414
build_table_i32((a, &ids), (b, &keys), (c, &values))
94149415
})
9415-
.collect();
9416+
.collect::<Vec<RecordBatch>>()
9417+
};
9418+
// left keys are 0..47, right keys 5..58
9419+
(side(47, 0, "a1", "b1", "c1"), side(53, 5, "a2", "b2", "c2"))
9420+
}
9421+
9422+
fn sort_merge_fallback_inputs(
9423+
batches: usize,
9424+
) -> (Arc<dyn ExecutionPlan>, Arc<dyn ExecutionPlan>) {
9425+
let (left, right) = sort_merge_fallback_batches(batches);
9426+
let exec = |batches: Vec<RecordBatch>| {
94169427
let schema = batches[0].schema();
94179428
TestMemoryExec::try_new_exec(&[batches], schema, None).unwrap()
94189429
as Arc<dyn ExecutionPlan>
94199430
};
9420-
// left keys are 0..47, right keys 5..58
9421-
(side(47, 0, "a1", "b1", "c1"), side(53, 5, "a2", "b2", "c2"))
9431+
(exec(left), exec(right))
94229432
}
94239433

94249434
/// `c1 < c2`, so it references both sides
@@ -9472,6 +9482,22 @@ mod tests {
94729482
)
94739483
}
94749484

9485+
/// An unbounded pool with `hash_join_max_build_size` set, so that only the
9486+
/// cap, or a reason to decline, decides whether a partition falls back.
9487+
fn sort_merge_fallback_capped_ctx(max_build_size: Option<usize>) -> Arc<TaskContext> {
9488+
let unlimited = sort_merge_fallback_task_ctx(None, DiskManagerBuilder::default());
9489+
let mut session_config = unlimited.session_config().clone();
9490+
session_config
9491+
.options_mut()
9492+
.execution
9493+
.hash_join_max_build_size = max_build_size;
9494+
Arc::new(
9495+
TaskContext::default()
9496+
.with_session_config(session_config)
9497+
.with_runtime(unlimited.runtime_env()),
9498+
)
9499+
}
9500+
94759501
fn sorted_rows(batches: &[RecordBatch]) -> Vec<String> {
94769502
batches_to_sort_string(batches)
94779503
.lines()
@@ -9699,6 +9725,88 @@ mod tests {
96999725
Ok(())
97009726
}
97019727

9728+
/// The fallback is declined whenever the join promises its probe side's
9729+
/// ordering, because a merge emits join-key order instead. The promise is
9730+
/// what matters, not the input: `maintains_input_order` makes it only for
9731+
/// the join types that emit every row while scanning the probe side, and
9732+
/// only an ordered probe input turns it into an advertised output ordering.
9733+
#[tokio::test]
9734+
async fn sort_merge_fallback_honors_a_promised_probe_ordering() -> Result<()> {
9735+
let (left, _) = sort_merge_fallback_inputs(32);
9736+
let (_, right) = sort_merge_fallback_batches(32);
9737+
let schema = right[0].schema();
9738+
// `a2` is the probe side's id column, ascending across batches
9739+
let ordering = datafusion_physical_expr_common::sort_expr::LexOrdering::new([
9740+
PhysicalSortExpr::new_default(Arc::new(Column::new_with_schema(
9741+
"a2", &schema,
9742+
)?)),
9743+
])
9744+
.unwrap();
9745+
let right = TestMemoryExec::try_new(&[right], schema, None)?
9746+
.try_with_sort_information(vec![ordering])?;
9747+
let right: Arc<dyn ExecutionPlan> =
9748+
Arc::new(TestMemoryExec::update_cache(&Arc::new(right)));
9749+
let on: JoinOn = vec![(
9750+
Arc::new(Column::new_with_schema("b1", &left.schema())?) as _,
9751+
Arc::new(Column::new_with_schema("b2", &right.schema())?) as _,
9752+
)];
9753+
let join = |join_type: JoinType| {
9754+
HashJoinExec::try_new(
9755+
Arc::clone(&left),
9756+
Arc::clone(&right),
9757+
on.clone(),
9758+
None,
9759+
&join_type,
9760+
None,
9761+
PartitionMode::Partitioned,
9762+
NullEquality::NullEqualsNothing,
9763+
false,
9764+
)
9765+
};
9766+
// A join that declined up front never registers the counter at all.
9767+
let fallbacks = |join: &HashJoinExec| {
9768+
join.metrics()
9769+
.unwrap()
9770+
.sum_by_name(SORT_MERGE_FALLBACK_COUNT_METRIC_NAME)
9771+
.map_or(0, |v| v.as_usize())
9772+
};
9773+
9774+
// An inner join promises the probe order, so under a cap that would
9775+
// otherwise switch it, it stays a hash join and keeps that order.
9776+
let inner = join(JoinType::Inner)?;
9777+
assert!(inner.properties().output_ordering().is_some());
9778+
let batches = common::collect(
9779+
inner.execute(0, sort_merge_fallback_capped_ctx(Some(1024)))?,
9780+
)
9781+
.await?;
9782+
let output = concat_batches(&inner.schema(), &batches)?;
9783+
let ids = output
9784+
.column_by_name("a2")
9785+
.unwrap()
9786+
.as_primitive::<Int32Type>();
9787+
assert!(
9788+
ids.values().is_sorted(),
9789+
"the output must stay in probe order"
9790+
);
9791+
assert_eq!(
9792+
fallbacks(&inner),
9793+
0,
9794+
"a promised ordering must stop the fallback"
9795+
);
9796+
9797+
// A left join never promises it, so the same ordered input does not
9798+
// stop the fallback.
9799+
let left_join = join(JoinType::Left)?;
9800+
assert!(left_join.properties().output_ordering().is_none());
9801+
common::collect(
9802+
left_join.execute(0, sort_merge_fallback_capped_ctx(Some(1024)))?,
9803+
)
9804+
.await?;
9805+
assert_eq!(fallbacks(&left_join), 1);
9806+
9807+
Ok(())
9808+
}
9809+
97029810
/// Without disk the join fails as before
97039811
#[tokio::test]
97049812
async fn sort_merge_fallback_needs_disk() -> Result<()> {
@@ -9760,23 +9868,10 @@ mod tests {
97609868
false,
97619869
)
97629870
};
9763-
let task_ctx = |max_build_size: Option<usize>| {
9764-
let unlimited =
9765-
sort_merge_fallback_task_ctx(None, DiskManagerBuilder::default());
9766-
let mut session_config = unlimited.session_config().clone();
9767-
session_config
9768-
.options_mut()
9769-
.execution
9770-
.hash_join_max_build_size = max_build_size;
9771-
Arc::new(
9772-
TaskContext::default()
9773-
.with_session_config(session_config)
9774-
.with_runtime(unlimited.runtime_env()),
9775-
)
9776-
};
9777-
97789871
let in_memory = join()?;
9779-
let expected = common::collect(in_memory.execute(0, task_ctx(None))?).await?;
9872+
let expected =
9873+
common::collect(in_memory.execute(0, sort_merge_fallback_capped_ctx(None))?)
9874+
.await?;
97809875
assert_eq!(
97819876
in_memory
97829877
.metrics()
@@ -9787,7 +9882,10 @@ mod tests {
97879882
);
97889883

97899884
let fallback = join()?;
9790-
let actual = common::collect(fallback.execute(0, task_ctx(Some(1024)))?).await?;
9885+
let actual = common::collect(
9886+
fallback.execute(0, sort_merge_fallback_capped_ctx(Some(1024)))?,
9887+
)
9888+
.await?;
97919889
assert_eq!(
97929890
fallback
97939891
.metrics()

‎datafusion/physical-plan/src/joins/hash_join/sort_merge_fallback.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@
5252
//! back is not by itself decisive: a single falling-back partition completes
5353
//! when the budget covers its sorts.
5454
//!
55+
//! A join that promises its probe side's ordering never falls back, because a
56+
//! merge emits join-key order instead. That covers more than inputs with a
57+
//! declared ordering: the planner pushes an `ORDER BY` on probe-side columns
58+
//! below an inner or right join precisely because the join keeps that order,
59+
//! so such queries stay on the in-memory path and still fail under memory
60+
//! pressure. Re-sorting the merge output to honor the promise is follow-up
61+
//! work.
62+
//!
5563
//! [`HashJoinExec`]: super::HashJoinExec
5664
//! [`PartitionMode::Partitioned`]: crate::joins::PartitionMode::Partitioned
5765

0 commit comments

Comments
 (0)