Skip to content

Commit 6cb6d2a

Browse files
committed
add smj spill test coverage
1 parent 994fc81 commit 6cb6d2a

6 files changed

Lines changed: 613 additions & 158 deletions

File tree

datafusion/core/tests/fuzz_cases/join_fuzz.rs

Lines changed: 147 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use std::time::SystemTime;
2020

2121
use crate::fuzz_cases::join_fuzz::JoinTestType::{HjSmj, NljHj};
2222

23-
use arrow::array::{ArrayRef, BinaryArray, Int32Array};
23+
use arrow::array::{ArrayRef, BinaryArray, Int32Array, StringArray};
2424
use arrow::compute::SortOptions;
2525
use arrow::datatypes::Schema;
2626
use arrow::record_batch::RecordBatch;
@@ -30,22 +30,24 @@ use datafusion::datasource::memory::MemorySourceConfig;
3030
use datafusion::datasource::source::DataSourceExec;
3131
use datafusion::logical_expr::{JoinType, Operator};
3232
use datafusion::physical_expr::expressions::BinaryExpr;
33-
use datafusion::physical_plan::collect;
3433
use datafusion::physical_plan::expressions::Column;
3534
use datafusion::physical_plan::joins::utils::{ColumnIndex, JoinFilter};
3635
use datafusion::physical_plan::joins::{
3736
HashJoinExec, NestedLoopJoinExec, PartitionMode, SortMergeJoinExec,
3837
};
38+
use datafusion::physical_plan::{ExecutionPlan, collect};
3939
use datafusion::prelude::{SessionConfig, SessionContext};
4040
use datafusion_common::{NullEquality, ScalarValue};
4141
use datafusion_execution::TaskContext;
4242
use datafusion_execution::disk_manager::{DiskManagerBuilder, DiskManagerMode};
43+
use datafusion_execution::memory_pool::FairSpillPool;
4344
use datafusion_execution::runtime_env::RuntimeEnvBuilder;
4445
use datafusion_physical_expr::PhysicalExprRef;
4546
use datafusion_physical_expr::expressions::Literal;
4647

4748
use itertools::Itertools;
48-
use rand::Rng;
49+
use rand::rngs::SmallRng;
50+
use rand::{Rng, SeedableRng};
4951
use test_utils::stagger_batch_with_seed;
5052

5153
// Determines what Fuzz tests needs to run
@@ -1126,133 +1128,131 @@ impl JoinFuzzTestCase {
11261128
}
11271129
}
11281130

1129-
/// Fuzz test: compare SMJ (with spilling) against HJ (no spill) for filtered
1130-
/// outer joins under memory pressure. This exercises the deferred filtering +
1131-
/// spill read-back path that unit tests can't easily cover with random data.
1131+
/// Compare a guaranteed-spilling SMJ against an unlimited-memory hash join
1132+
/// for filtered materializing joins.
11321133
#[tokio::test]
11331134
async fn test_filtered_join_spill_fuzz() {
1134-
let join_types = [JoinType::Left, JoinType::Right, JoinType::Full];
1135+
let join_types = [
1136+
JoinType::Inner,
1137+
JoinType::Left,
1138+
JoinType::Right,
1139+
JoinType::Full,
1140+
];
1141+
let input1 = make_spill_join_batches(256, 32, 512, 1);
1142+
let input2 = make_spill_join_batches(256, 32, 512, 2);
1143+
let schema1 = input1[0].schema();
1144+
let schema2 = input2[0].schema();
1145+
let filter = col_lt_col_filter(Arc::clone(&schema1), Arc::clone(&schema2));
1146+
let on = vec![
1147+
(
1148+
Arc::new(Column::new_with_schema("a", &schema1).unwrap()) as _,
1149+
Arc::new(Column::new_with_schema("a", &schema2).unwrap()) as _,
1150+
),
1151+
(
1152+
Arc::new(Column::new_with_schema("b", &schema1).unwrap()) as _,
1153+
Arc::new(Column::new_with_schema("b", &schema2).unwrap()) as _,
1154+
),
1155+
];
11351156

11361157
let runtime_spill = RuntimeEnvBuilder::new()
1137-
.with_memory_limit(4096, 1.0)
1158+
.with_memory_pool(Arc::new(FairSpillPool::new(1024)))
11381159
.with_disk_manager_builder(
11391160
DiskManagerBuilder::default().with_mode(DiskManagerMode::OsTmpDirectory),
11401161
)
11411162
.build_arc()
11421163
.unwrap();
11431164

1144-
for join_type in &join_types {
1145-
for (left_extra, right_extra) in [(true, true), (false, true), (true, false)] {
1146-
let input1 = make_staggered_batches_i32(1000, left_extra);
1147-
let input2 = make_staggered_batches_i32(1000, right_extra);
1165+
for join_type in join_types {
1166+
for batch_size in [2, 50] {
1167+
let session_config = SessionConfig::new().with_batch_size(batch_size);
11481168

1149-
let schema1 = input1[0].schema();
1150-
let schema2 = input2[0].schema();
1151-
let filter = col_lt_col_filter(schema1.clone(), schema2.clone());
1152-
1153-
let on = vec![
1154-
(
1155-
Arc::new(Column::new_with_schema("a", &schema1).unwrap()) as _,
1156-
Arc::new(Column::new_with_schema("a", &schema2).unwrap()) as _,
1157-
),
1158-
(
1159-
Arc::new(Column::new_with_schema("b", &schema1).unwrap()) as _,
1160-
Arc::new(Column::new_with_schema("b", &schema2).unwrap()) as _,
1161-
),
1162-
];
1163-
1164-
for batch_size in [2, 49, 100] {
1165-
let session_config = SessionConfig::new().with_batch_size(batch_size);
1166-
1167-
// HJ baseline (no memory limit)
1168-
let left_hj = MemorySourceConfig::try_new_exec(
1169-
std::slice::from_ref(&input1),
1170-
schema1.clone(),
1171-
None,
1172-
)
1173-
.unwrap();
1174-
let right_hj = MemorySourceConfig::try_new_exec(
1175-
std::slice::from_ref(&input2),
1176-
schema2.clone(),
1169+
let left_hj = MemorySourceConfig::try_new_exec(
1170+
std::slice::from_ref(&input1),
1171+
Arc::clone(&schema1),
1172+
None,
1173+
)
1174+
.unwrap();
1175+
let right_hj = MemorySourceConfig::try_new_exec(
1176+
std::slice::from_ref(&input2),
1177+
Arc::clone(&schema2),
1178+
None,
1179+
)
1180+
.unwrap();
1181+
let hj = Arc::new(
1182+
HashJoinExec::try_new(
1183+
left_hj,
1184+
right_hj,
1185+
on.clone(),
1186+
Some(filter.clone()),
1187+
&join_type,
11771188
None,
1189+
PartitionMode::Partitioned,
1190+
NullEquality::NullEqualsNothing,
1191+
false,
11781192
)
1179-
.unwrap();
1180-
let hj = Arc::new(
1181-
HashJoinExec::try_new(
1182-
left_hj,
1183-
right_hj,
1184-
on.clone(),
1185-
Some(filter.clone()),
1186-
join_type,
1187-
None,
1188-
PartitionMode::Partitioned,
1189-
NullEquality::NullEqualsNothing,
1190-
false,
1191-
)
1192-
.unwrap(),
1193-
);
1194-
let ctx_hj = SessionContext::new_with_config(session_config.clone());
1195-
let hj_collected = collect(hj, ctx_hj.task_ctx()).await.unwrap();
1193+
.unwrap(),
1194+
);
1195+
let ctx_hj = SessionContext::new_with_config(session_config.clone());
1196+
let hj_collected = collect(hj, ctx_hj.task_ctx()).await.unwrap();
11961197

1197-
// SMJ with spilling
1198-
let left_smj = MemorySourceConfig::try_new_exec(
1199-
std::slice::from_ref(&input1),
1200-
schema1.clone(),
1201-
None,
1202-
)
1203-
.unwrap();
1204-
let right_smj = MemorySourceConfig::try_new_exec(
1205-
std::slice::from_ref(&input2),
1206-
schema2.clone(),
1207-
None,
1198+
let left_smj = MemorySourceConfig::try_new_exec(
1199+
std::slice::from_ref(&input1),
1200+
Arc::clone(&schema1),
1201+
None,
1202+
)
1203+
.unwrap();
1204+
let right_smj = MemorySourceConfig::try_new_exec(
1205+
std::slice::from_ref(&input2),
1206+
Arc::clone(&schema2),
1207+
None,
1208+
)
1209+
.unwrap();
1210+
let smj = Arc::new(
1211+
SortMergeJoinExec::try_new(
1212+
left_smj,
1213+
right_smj,
1214+
on.clone(),
1215+
Some(filter.clone()),
1216+
join_type,
1217+
vec![SortOptions::default(); on.len()],
1218+
NullEquality::NullEqualsNothing,
12081219
)
1209-
.unwrap();
1210-
let smj = Arc::new(
1211-
SortMergeJoinExec::try_new(
1212-
left_smj,
1213-
right_smj,
1214-
on.clone(),
1215-
Some(filter.clone()),
1216-
*join_type,
1217-
vec![SortOptions::default(); on.len()],
1218-
NullEquality::NullEqualsNothing,
1219-
)
1220-
.unwrap(),
1221-
);
1222-
let task_ctx_spill = Arc::new(
1223-
TaskContext::default()
1224-
.with_session_config(session_config)
1225-
.with_runtime(Arc::clone(&runtime_spill)),
1226-
);
1227-
let smj_collected = collect(smj, task_ctx_spill).await.unwrap();
1220+
.unwrap(),
1221+
);
1222+
let task_ctx_spill = Arc::new(
1223+
TaskContext::default()
1224+
.with_session_config(session_config)
1225+
.with_runtime(Arc::clone(&runtime_spill)),
1226+
);
1227+
let smj_collected =
1228+
collect(Arc::clone(&smj) as Arc<dyn ExecutionPlan>, task_ctx_spill)
1229+
.await
1230+
.unwrap();
1231+
1232+
assert!(
1233+
smj.metrics().unwrap().spill_count().unwrap_or_default() > 0,
1234+
"expected SMJ to spill for {join_type:?} batch_size={batch_size}",
1235+
);
12281236

1229-
let hj_rows: usize = hj_collected.iter().map(|b| b.num_rows()).sum();
1230-
let smj_rows: usize = smj_collected.iter().map(|b| b.num_rows()).sum();
1237+
let hj_rows: usize = hj_collected.iter().map(|b| b.num_rows()).sum();
1238+
let smj_rows: usize = smj_collected.iter().map(|b| b.num_rows()).sum();
1239+
assert_eq!(
1240+
hj_rows, smj_rows,
1241+
"row count mismatch for {join_type:?} batch_size={batch_size}: \
1242+
HJ={hj_rows} SMJ={smj_rows}",
1243+
);
12311244

1245+
if hj_rows > 0 {
1246+
let hj_fmt = pretty_format_batches(&hj_collected).unwrap().to_string();
1247+
let smj_fmt = pretty_format_batches(&smj_collected).unwrap().to_string();
1248+
let mut hj_sorted: Vec<&str> = hj_fmt.trim().lines().collect();
1249+
hj_sorted.sort_unstable();
1250+
let mut smj_sorted: Vec<&str> = smj_fmt.trim().lines().collect();
1251+
smj_sorted.sort_unstable();
12321252
assert_eq!(
1233-
hj_rows, smj_rows,
1234-
"Row count mismatch for {join_type:?} batch_size={batch_size} \
1235-
left_extra={left_extra} right_extra={right_extra}: \
1236-
HJ={hj_rows} SMJ={smj_rows}"
1253+
hj_sorted, smj_sorted,
1254+
"content mismatch for {join_type:?} batch_size={batch_size}",
12371255
);
1238-
1239-
if hj_rows > 0 {
1240-
let hj_fmt =
1241-
pretty_format_batches(&hj_collected).unwrap().to_string();
1242-
let smj_fmt =
1243-
pretty_format_batches(&smj_collected).unwrap().to_string();
1244-
1245-
let mut hj_sorted: Vec<&str> = hj_fmt.trim().lines().collect();
1246-
hj_sorted.sort_unstable();
1247-
let mut smj_sorted: Vec<&str> = smj_fmt.trim().lines().collect();
1248-
smj_sorted.sort_unstable();
1249-
1250-
assert_eq!(
1251-
hj_sorted, smj_sorted,
1252-
"Content mismatch for {join_type:?} batch_size={batch_size} \
1253-
left_extra={left_extra} right_extra={right_extra}"
1254-
);
1255-
}
12561256
}
12571257
}
12581258
}
@@ -1347,3 +1347,39 @@ fn make_staggered_batches_binary(
13471347
// preserve your existing randomized partitioning
13481348
stagger_batch_with_seed(batch, 42)
13491349
}
1350+
1351+
/// Sorted, low-cardinality inputs whose wide payloads force SMJ key-group
1352+
/// spilling. `(a, b)` is sorted (`b` is constant) and `x` is nullable to
1353+
/// exercise filter NULL handling.
1354+
fn make_spill_join_batches(
1355+
len: usize,
1356+
num_keys: i32,
1357+
payload_len: usize,
1358+
seed: u64,
1359+
) -> Vec<RecordBatch> {
1360+
let mut rng = SmallRng::seed_from_u64(seed);
1361+
1362+
let mut keys: Vec<i32> = (0..len).map(|_| rng.random_range(0..num_keys)).collect();
1363+
keys.sort_unstable();
1364+
let a = Int32Array::from_iter_values(keys);
1365+
let b = Int32Array::from_iter_values(std::iter::repeat_n(0, len));
1366+
let x = Int32Array::from_iter((0..len).map(|_| {
1367+
if rng.random_range(0..10) == 0 {
1368+
None
1369+
} else {
1370+
Some(rng.random_range(0..1000))
1371+
}
1372+
}));
1373+
1374+
let payload = "a".repeat(payload_len);
1375+
let p = StringArray::from_iter_values(std::iter::repeat_n(payload.as_str(), len));
1376+
let batch = RecordBatch::try_from_iter(vec![
1377+
("a", Arc::new(a) as ArrayRef),
1378+
("b", Arc::new(b) as ArrayRef),
1379+
("x", Arc::new(x) as ArrayRef),
1380+
("p", Arc::new(p) as ArrayRef),
1381+
])
1382+
.unwrap();
1383+
1384+
stagger_batch_with_seed(batch, 7)
1385+
}

0 commit comments

Comments
 (0)