Conversation
…s UNKNOWN A null-aware LeftAnti join with a join filter ignored the filter for NULL keys: one NULL probe key removed every build row, even when the filter excluded that NULL row for every build row. A null-aware LeftAnti join with correlation scope keys failed to plan. Treat a null-aware LeftAnti or LeftMark join as correlated when it has scope keys or a join filter. Correlated joins record the UNKNOWN decision per build row in the null-indices bitmap: the candidate (build, probe) pairs come from the scope map, or from all pairs when there are no scope keys, and the join filter decides which pairs count. The LeftAnti final stage drops the rows marked UNKNOWN. JoinSelection only swaps an uncorrelated null-aware LeftAnti. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ware The hash join now applies the join filter when it marks UNKNOWN rows, so a NOT IN mark join no longer needs to fall back to a non null-aware join when a non-equality correlation stays behind as a join filter. The fallback gave FALSE instead of NULL, so NOT (x IN (...)) returned extra rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds sqllogictest regression tests for apache#25336 (expected results checked with DuckDB and PostgreSQL) and HashJoinExec unit tests for null-aware LeftAnti and LeftMark joins that have a join filter and no scope keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25339 +/- ##
========================================
Coverage 81.92% 81.92%
========================================
Files 1135 1135
Lines 427573 427693 +120
Branches 427573 427693 +120
========================================
+ Hits 350279 350391 +112
- Misses 56367 56370 +3
- Partials 20927 20932 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working on this. The approach looks good to me, especially keeping the residual join filter involved when determining the per-build-row UNKNOWN state for correlated NOT IN. I also like the added coverage for both anti and mark joins.
I left one non-blocking performance suggestion below. Nothing that needs to hold up the PR.
| None => { | ||
| let probe_rows = | ||
| UInt32Array::from_iter_values(0..state.batch.num_rows() as u32); | ||
| for_each_cross_product( |
There was a problem hiding this comment.
One potential performance concern here: when there are no scope keys, we evaluate the residual filter for every NULL build row × probe row pair. The symmetric path below does the same for NULL probe rows. For a nullable non-equality-correlated NOT IN, that could add quadratic work, including for build rows that have already been marked UNKNOWN.
Would it be worth adding a small bounded benchmark or targeted performance regression test for this path? As a follow-up optimization, we might also be able to skip build rows that are already marked UNKNOWN, although we'd need to be careful about volatile or erroring filter expressions.
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @adriangb , 2 non-blocking suggestions
| None => { | ||
| let build_rows = | ||
| UInt64Array::from_iter_values(0..left_data.batch().num_rows() as u64); | ||
| for_each_cross_product( |
There was a problem hiding this comment.
The case with no scope keys re-checks build rows that are already marked UNKNOWN
Without scope keys, case 2 evaluates the filter for every (build row × NULL probe row) pair in every probe batch, including build rows already set in null_indices_bitmap. Those bits never clear, so that work is wasted. 20K outer × 10K NULL inner with i.z < o.z spends 8.85s in join_time (debug build). Skip marked rows and stop once none are left (same for case 1 at :1466):
None => {
let num_build_rows = left_data.batch().num_rows();
for probe_rows in null_probe_rows.values().chunks(batch_size.max(1)) {
let build_rows = {
let bitmap = left_data.null_indices_bitmap().lock();
UInt64Array::from_iter_values(
(0..num_build_rows)
.filter(|i| !bitmap.get_bit(*i))
.map(|i| i as u64),
)
};
if build_rows.is_empty() {
break;
}
let probe_rows = UInt32Array::from(probe_rows.to_vec());
for_each_cross_product(&build_rows, &probe_rows, batch_size, &mut mark)?;
}
}Fine to handle in a follow-up
| num_keys: usize, | ||
| has_filter: bool, | ||
| ) -> Result<Self> { | ||
| let correlated = num_keys > 1 || has_filter; |
There was a problem hiding this comment.
correlated = num_keys > 1 || has_filter assumes on[0] is the NOT IN value key. When the value has no outer columns, 1 = i.id is pushed into the subquery, so on[0] becomes the correlation key o.g = i.g, and a NULL o.g is marked UNKNOWN:
CREATE TABLE o(id INT, g INT, z INT) AS VALUES (1,1,10),(2,NULL,10),(3,2,10);
CREATE TABLE i(id INT, g INT, z INT) AS VALUES (1,1,5),(5,2,5),(NULL,3,5);
SELECT id FROM o WHERE 1 NOT IN (SELECT i.id FROM i WHERE i.g = o.g AND i.z < o.z);
-- expected 2, 3; returns 3The form without AND i.z < o.z is also wrong and doesn't go through the new code, so this predates the PR. Fine as a follow-up: in build_join, only set null_aware when the in-predicate's outer side references a left column.
There was a problem hiding this comment.
Agreed, this is a bug in main:
main: R1 (with i.z < o.z) → 1 row R2 (equality only) → 1 row
PR: R1 → 1 row R2 → 1 row
|
@jayzhan211 @kosiew I opened #25386 w/ benchmarks for this change. Could we merge that first so we can look at before/afterS? |
Which issue does this PR close?
Rationale for this change
A correlated
NOT INsubquery gives wrong results when the correlation is not an equality and the subquery column contains NULL. There is no error and no warning.The same gap also makes an equality-correlated
NOT INin aWHEREclause fail to plan:The fix sketch in the issue (turn off
null_awarefor aLeftAntijoin that has a join filter) does not work. I tried it: the plain anti join ignores NULLs completely, so queries that are correct today start to return rows. For example,id NOT IN (SELECT t2.id FROM t2 WHERE t2.z > t1.z)must return no rows, and returns1, 2, 4, NULLwith that change.What changes are included in this PR?
The hash join already had the right mechanism for correlated
NOT INmark joins with equality correlation keys: a per-build-row bitmap that records "this row'sNOT INis UNKNOWN". This PR uses that mechanism for every correlated null-aware join and makes it apply the join filter. The commits are split for review:HashJoinExec: a null-awareLeftAntiorLeftMarkjoin is correlated when it has correlation scope keys or a join filter. For a NULL value on either side, the join finds the candidate (build, probe) row pairs through the scope key hash map, or takes all pairs when there are no scope keys. The join filter then decides which pairs make a build row UNKNOWN. TheLeftAntifinal stage drops those rows. The extra work is only for rows that have a NULL value key, so it is zero when the data has no NULLs.JoinSelectionswaps a null-awareLeftAntionly when it has a single key and no filter.DecorrelatePredicateSubquery: aNOT INmark join with a non-equality correlation is now planned as null-aware. FourEXPLAINresults insubquery.sltchange: the mark join now showsnull_aware, and in one of them the join is no longer swapped toRightMark, because null-aware mark joins are never swapped.What is the testing strategy for this PR?
null_aware_anti_join.sltandnull_aware_mark_join.slt: the queries from the issue, NULL outer values with empty and non-empty subquery results, equality plus non-equality correlation, a filter on the subquery value itself, the positiveINform, the mark column throughIS NULL/IS TRUE/IS FALSE/NOT ... ORand directly in aSELECTlist, and runs withbatch_size = 1. I checked all expected results with DuckDB 1.5.2 and PostgreSQL 17.11. 14 of these cases fail onmain.HashJoinExecunit tests for a null-awareLeftAntiandLeftMarkjoin that has a join filter and no scope keys, at all batch sizes.Are there any user-facing changes?
Queries that returned wrong results now return correct results, and correlated
NOT INwith an equality correlation in aWHEREclause no longer fails to plan. There are no public API changes.🤖 Generated with Claude Code