Skip to content

feat: build multi-column null-aware NOT IN on the subquery side - #26213

Open
viirya wants to merge 2 commits into
apache:mainfrom
viirya:na-multikey-right-anti
Open

viirya wants to merge 2 commits into
apache:mainfrom
viirya:na-multikey-right-anti

Conversation

@viirya

@viirya viirya commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

A multi-column (a, b) NOT IN (SELECT x, y ...), and a correlated NOT IN with scope keys or a join filter, plans as a null-aware LeftAnti hash join that JoinSelection never swaps. The outer side is always the build side and is held in memory even when the subquery is tiny. With @comphead's repro (2M-row outer table, 100-row subquery, --memory-limit 64M), the scalar NOT IN runs but the multi-column one fails with Resources exhausted ... HashJoinInput.

What changes are included in this PR?

  • JoinSelection may swap every null-aware LeftAnti into a RightAnti that builds on the subquery side, not only the single-key, unfiltered one. The swap keeps null_aware_value_keys and the key order, and the join filter is swapped with the inputs. A null-aware LeftMark is not swapped.
  • NullAwareMode::RightAnti now carries { correlated, value_keys }, and NullAwareMode::try_new accepts a multi-key or filtered null-aware RightAnti (still CollectLeft only).
    • Uncorrelated (single key, no filter): unchanged. A NULL build key silences every batch and NULL probe keys are dropped.
    • Correlated: each outer (probe) row is decided while streaming. An exact match is found by the main hash lookup (FALSE). The probe rows that are UNKNOWN are found once per probe batch by the existing NULL candidate pairing of the correlated LeftAnti: NULL-valued build rows against all probe rows, and NULL-valued probe rows against all build rows, narrowed by the scope maps, then the element-wise "no definite mismatch" check (retain_value_mismatch_free) and the join filter. The UNKNOWN rows are dropped from the output.
  • The pairing helpers in stream.rs take an UnknownMarks that says which side of a candidate pair is marked: build rows in the shared null-indices bitmap (LeftAnti / LeftMark, unchanged) or rows of the current probe batch (RightAnti). A correlated RightAnti builds the scope maps but not the per-build-row bitmap, and has no state shared across probe partitions.

The build side is not deduplicated, since that does not hold with a join filter. The NULL pairing still costs (NULL-valued rows) x (rows on the other side) without scope keys; #26088 narrows that.

What is the testing strategy for this PR?

  • exec.rs unit tests:
    • test_null_aware_multi_column_right_anti runs a two-column NOT IN as LeftAnti and as the swapped RightAnti across batch sizes, including an all-NULL-valued subquery (empty full-key map) and an empty subquery.
    • test_null_aware_left_anti_multi_column_scope also checks the swapped RightAnti.
    • The validation tests now accept a multi-key or filtered null-aware RightAnti.
  • join_selection.rs: a filtered null-aware LeftAnti swaps, and the filter's sides are swapped too. A multi-key null-aware LeftAnti swaps with value_keys 1 and 2 kept.
  • null_aware_multi_column.slt adds datafusion.optimizer.join_reordering=true,false to its configMatrix, so every result is checked with the RightAnti and the LeftAnti plan. Plans in null_aware_anti_join.slt and subquery_projection.slt are updated; no query result changed.
  • null_aware_join_fuzz.rs: half of the seeds use a much smaller subquery table, and the test counts the null-aware plans it ran and asserts it reached LeftAnti, RightAnti and a correlated (multi-key or filtered) RightAnti. A run had 228 / 1692 / 1602 of them. Disabling the mismatch check on the RightAnti path makes the fuzz fail.
  • memory_limit/null_aware_not_in.rs: a small-scale version of the repro above. A 200k-row outer table against a 101-row subquery (one NULL-valued row) under a 2 MB limit gives the right count with join reordering on, and runs out of memory in HashJoinInput with it off. The test fails without the JoinSelection change.

null_aware_join benchmark, Q10 / Q11 / Q12 (multi-column, which now plan as RightAnti), mean of 3 interleaved runs, in ms:

main this PR
NAJ_ROWS=10000 0.343 / 3.508 / 0.572 0.348 / 3.520 / 0.557
NAJ_ROWS=20000 0.394 / 7.800 / 0.756 0.414 / 6.634 / 0.627

They take about the same time because the NULL pairs are the same. The gain is that the build side is now the subquery, which the memory-limit test covers. The other queries of the suite stay within run-to-run noise.

Are there any user-facing changes?

Multi-column and correlated NOT IN subqueries can now plan as a null-aware RightAnti hash join built on the subquery side, so EXPLAIN output changes and memory use drops when the subquery is the smaller side. No public API changes.

viirya and others added 2 commits October 11, 2026 10:21
A multi-column or correlated `NOT IN` plans as a null-aware LeftAnti hash
join that JoinSelection never swapped, so the outer side was always the
build side and held in memory even when the subquery was small.

Let JoinSelection swap every null-aware LeftAnti into a RightAnti. A
correlated null-aware RightAnti (more than one key or a join filter) now
decides each outer (probe) row while streaming: it reuses the NULL
candidate pairing of the correlated LeftAnti (scope maps, the element-wise
mismatch check and the join filter), but marks the UNKNOWN probe rows of
the current batch instead of build rows in the shared bitmap, and drops
them from the anti join output.

Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Oct 11, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.17040% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.80%. Comparing base (454ca08) to head (fa12ec3).

Files with missing lines Patch % Lines
...tafusion/physical-plan/src/joins/hash_join/exec.rs 89.69% 0 Missing and 10 partials ⚠️
...fusion/physical-plan/src/joins/hash_join/stream.rs 97.60% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26213      +/-   ##
==========================================
- Coverage   82.80%   82.80%   -0.01%     
==========================================
  Files        1149     1149              
  Lines      452213   452325     +112     
  Branches   452213   452325     +112     
==========================================
+ Hits       374473   374560      +87     
- Misses      54945    54954       +9     
- Partials    22795    22811      +16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@viirya
viirya requested review from comphead and sunchao October 11, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate optimizer Optimizer rules physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build multi-column null-aware NOT IN on the subquery side (multi-key null-aware RightAnti)

2 participants