fix(scheduler): execute null-aware anti joins in one task - #2188
Conversation
|
Thanks for picking this up. The hang is real, and the I am worried about the reordering in I built the branch and ran a
The 56 and 75 row outputs are the same values repeated 3 or 4 times each, including values that should have been anti-joined away entirely. So the branch does fix the hang, it just turns it into a silently wrong answer, which I think is the worse of the two. My read is that neither Two smaller things while you are in here:
One more thing you may want to know, unrelated to your changes: Ballista defaults Here is the test I used, in case it is useful. Drop it in as ballista/client/tests/null_aware.rs// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
#[cfg(test)]
#[cfg(feature = "standalone")]
mod null_aware {
use ballista::prelude::SessionContextExt;
use datafusion::arrow::util::pretty::pretty_format_batches;
use datafusion::prelude::*;
use std::fs;
use std::path::Path;
fn write_tables(dir: &Path, t2_has_null: bool) {
// t1: 4 files -> 4 partitions, values 0..19
let t1 = dir.join("t1");
fs::create_dir_all(&t1).unwrap();
for i in 0..4i32 {
let mut s = String::from("a\n");
for v in 0..5i32 {
s.push_str(&format!("{}\n", i * 5 + v));
}
fs::write(t1.join(format!("p{i}.csv")), s).unwrap();
}
// t2: 4 files -> 4 partitions. One file optionally holds a NULL key.
// The second column keeps the empty field unambiguous, since a bare
// blank line is skipped by the CSV reader rather than read as NULL.
let t2 = dir.join("t2");
fs::create_dir_all(&t2).unwrap();
fs::write(t2.join("p0.csv"), "b,tag\n0,x\n1,x\n").unwrap();
fs::write(
t2.join("p1.csv"),
if t2_has_null { "b,tag\n,x\n" } else { "b,tag\n2,x\n" },
)
.unwrap();
fs::write(t2.join("p2.csv"), "b,tag\n3,x\n").unwrap();
fs::write(t2.join("p3.csv"), "b,tag\n4,x\n").unwrap();
}
async fn register(ctx: &SessionContext, dir: &Path) {
for name in ["t1", "t2"] {
ctx.register_csv(
name,
dir.join(name).to_str().unwrap(),
CsvReadOptions::new().has_header(true).file_extension(".csv"),
)
.await
.unwrap();
}
}
const QUERY: &str = "select a from t1 where a not in (select b from t2) order by a";
async fn run_case(case: &str, t2_has_null: bool) {
let dir = std::env::temp_dir().join(format!("ballista_null_aware_{case}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_tables(&dir, t2_has_null);
// single process DataFusion is the ground truth
let df_ctx = SessionContext::new_with_config(
SessionConfig::new().with_target_partitions(4),
);
register(&df_ctx, &dir).await;
let df_batches = df_ctx.sql(QUERY).await.unwrap().collect().await.unwrap();
let df_out = pretty_format_batches(&df_batches).unwrap().to_string();
println!("=== [{case}] DataFusion result ===\n{df_out}");
let mut mismatches = vec![];
for variant in ["default", "prefer_hash_join", "aqe"] {
let ctx = SessionContext::standalone().await.unwrap();
let mut settings = vec![];
if variant == "aqe" {
settings.push("SET ballista.planner.adaptive.enabled = true");
}
if variant != "default" {
settings.push("SET datafusion.optimizer.prefer_hash_join = true");
}
for stmt in settings {
ctx.sql(stmt).await.unwrap().collect().await.unwrap();
}
register(&ctx, &dir).await;
match ctx.sql(QUERY).await.unwrap().collect().await {
Ok(batches) => {
let out = pretty_format_batches(&batches).unwrap().to_string();
println!("=== [{case}/{variant}] Ballista result ===\n{out}");
if out.trim() != df_out.trim() {
mismatches.push(format!("[{case}/{variant}] differs from DataFusion"));
}
}
Err(e) => {
println!("=== [{case}/{variant}] Ballista failed ===\n{e}");
mismatches.push(format!("[{case}/{variant}] failed: {e}"));
}
}
}
assert!(mismatches.is_empty(), "{mismatches:#?}");
}
#[tokio::test]
async fn not_in_with_null_in_subquery() {
run_case("with_null", true).await;
}
#[tokio::test]
async fn not_in_without_null_in_subquery() {
run_case("no_null", false).await;
}
}Note that the |
0933320 to
dc0d54f
Compare
55e3e27 to
43a31b4
Compare
Thanks, @andygrove. You were right. I reproduced the 56/75-row incorrect results with your test and revised the PR. The updated implementation now:
I adapted your standalone test to cover The default sort-merge path still loses the null-aware flag before scheduler planning. I filed #2193 for that separate issue. |
Null-aware LeftAnti joins cannot be swapped and DataFusion coordinates their visited-row and NULL state only within one process. Preserve CollectLeft, collect the build side, and coalesce the probe side so static and AQE plans run the join in exactly one task. Reject disabled or known oversized single-task builds through the Ballista broadcast threshold. Add optimizer, stage-lowering, threshold, and standalone distributed NOT IN regression tests. Closes apache#2187 Signed-off-by: Phillip LeBlanc <879445+phillipleblanc@users.noreply.github.com>
43a31b4 to
509a688
Compare
|
Thanks @phillipleblanc. I'm taking another look at this now. |
|
|
andygrove
left a comment
There was a problem hiding this comment.
It's unfortunate that we have to collapse down to a single task and also reject any outer table > 10 MB, but it seems we have no choice based on current DF design. This is better than producing incorrect results.
Thanks again for fixing this @phillipleblanc
|
I filed a follow on issue #2198 to remove the single task restriction. I'm investigating. |
Which issue does this PR close?
Closes #2187.
Rationale for this change
DataFusion plans
NOT INpredicates as null-awareLeftAntihash joins. These joins cannot be swapped toRightAnti. They also needPartitionMode::CollectLeft.CollectLeftalone is not enough in a distributed plan. DataFusion coordinates visited build rows and probe-side NULL state with in-process atomics. Running the join in several probe tasks gives each task independent state and produces duplicated or incorrect rows.The four-partition regression case returned 56 rows instead of 0 when the subquery contained
NULL, and 75 rows instead of 15 without theNULL.This is a fresh implementation against Apache
main, based on the downstream report in spiceai#58 and the distributed reproduction from review.What changes are included in this PR?
LeftAnti(CollectLeft)with a single probe task.ballista.optimizer.broadcast_join_threshold_bytesas a safety limit when the build size is known. A value of0rejects null-aware anti joins instead of running an unsafe plan.NOT INregression tests.Are there any user-facing changes?
NOT INqueries that retain a null-aware hash join now execute with correct NULL semantics under static and adaptive planning. A known oversized build side now returns a clear unsupported-plan error rather than hanging or returning incorrect data.Ballista's default sort-merge planning can discard the null-aware flag before these scheduler rules run. #2193 tracks that separate path.
Test plan
cargo test -p ballista-scheduler --lib— 276 passed, 2 ignoredcargo test -p ballista --test null_aware— 2 passedcargo clippy --all-targets --package ballista-scheduler --all-features -- -D warningscargo clippy --all-targets --package ballista --all-features -- -D warningscargo fmt --all -- --check./ci/scripts/rust_config_docs_check.sh