Skip to content

fix: request a null-aware mark join wherever a NULL mark is observable - #25560

Draft
adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:na-optimizer
Draft

adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:na-optimizer

Conversation

@adriangb

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Important

Stacked on #25558 and #25559. GitHub cannot base a PR on another fork's branch, so the diff here shows all three. Review only the last commit (fix: request a null-aware mark join wherever a NULL mark is observable). Draft until the other two land; I will rebase then.

Rationale for this change

Decorrelation asked for null-aware semantics only for some NOT IN mark joins. A non-equality correlation leaves a residual join filter rather than an equi-join key, and that shape was planned without it, so a NULL on the subquery side read as FALSE instead of UNKNOWN:

CREATE TABLE oc(id INT, g INT, h INT) AS VALUES (2,2,0),(1,2,0),(9,2,5),(3,1,0);
CREATE TABLE ic(id INT, k INT) AS VALUES (1,1),(NULL,1),(2,2);
SELECT id FROM oc WHERE oc.h > 1 OR oc.id NOT IN (SELECT i.id FROM ic i WHERE i.k < oc.g) ORDER BY id;

returns 2, 3, 9; DuckDB returns 3, 9. Row id = 2 is UNKNOWN, not TRUE.

#25559 gives the executor the machinery. This asks for it in the remaining place.

What changes are included in this PR?

A mark join needs null-aware semantics only where a NULL mark can behave differently from a FALSE mark. AND/OR give TRUE only from TRUE, and a Filter keeps a row only when the predicate is TRUE, so a non-negated IN reached through nothing but AND/OR is identical with a FALSE mark and must stay on the plain join. Requesting it there is expensive: 1–2 ms → 7.4 s at 100k × 100k.

That test is made per subquery occurrence, in a recursion that only knows AND/OR. The permissive outcome lives in one arm whose pattern is its own proof — a non-negated IN whose value holds no subquery, reached through nothing but AND/OR frames — and everything else, including future Expr variants, takes the null-aware branch.

Two further changes in build_join:

  • a constant IN value is projected as an outer column so the equality becomes on[0], the key position the executor reads as the NOT IN value key. Otherwise a correlation takes that slot and the value-key NULL rules are applied to the wrong key;
  • null_aware is computed once instead of being re-derived for the constant projection, the mark branch and the anti branch.

Net effect on the optimizer source is −14 lines.

What is the testing strategy for this PR?

The coverage landed in #25558; this PR flips the remaining expectations, including the Q08 canary, so the diff shows the behaviour change. The plan pins added in #25558 guard the negative direction — that a positive IN stays on the plain join — which no result assertion can catch, since a needless null-aware join is correct, only slower. Mutation testing over the decision confirms it: mutants that widen null-awareness are killed only by those pins.

Are there any user-facing changes?

Correlated NOT IN inside a larger predicate returns correct results.

🤖 Generated with Claude Code

adriangb and others added 3 commits September 20, 2026 21:57
Adds sqllogictest and benchmark coverage for correlated `NOT IN`, pinned to
what DataFusion does today. Several of these expectations are wrong, and a few
shapes do not plan at all; each such block carries a note and a link to
apache#25336. The fixes flip them, so the
flip is visible in those diffs rather than buried in a large change.

sqllogictest, in `null_aware_anti_join.slt` and `null_aware_mark_join.slt`:
- correlated `NOT IN` with a non-equality correlation, which stays a residual
  join filter;
- a correlation that names only outer columns, so it cannot become an
  equi-join key;
- a constant value expression with and without a correlation;
- a subquery inside the `IN` value, both spellings of the outer `IN`;
- `IS NOT NULL` over a subquery predicate and a comparison between two marks,
  the contexts that can tell a NULL mark from a FALSE mark;
- plan pins for the joins the planner chooses in each case.

Benchmarks:
- Q09, a correlated non-negated `IN`. It must not use a null-aware join, a
  direction none of Q01-Q08 covers, and it passes today;
- correctness canaries on Q05-Q08, each comparing the `NOT IN` result with a
  reference that does not use `NOT IN`. All four currently disagree, so they
  are pinned to `false`.

Expected results verified with DuckDB and PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…NOWN

A correlated `NOT IN` whose correlation is not an equi-join key leaves a
residual join filter. The null-aware hash join ignored it when deciding
whether a NULL on the subquery side makes `NOT IN` UNKNOWN, so a NULL that the
filter excludes still poisoned every outer row:

    CREATE TABLE oc(id INT, g INT) AS VALUES (1,5),(2,5),(3,0),(4,NULL),(NULL,5),(NULL,0);
    CREATE TABLE ic(id INT) AS VALUES (1),(NULL);
    SELECT id, g FROM oc WHERE oc.id NOT IN (SELECT ic.id FROM ic WHERE oc.g > 0);

returned no rows; DuckDB and PostgreSQL return three. The plan was already
correct, `LeftAnti ... Filter: oc.g > Int32(0) null_aware`, so this is purely
an execution fix.

A NULL now makes `NOT IN` UNKNOWN only for the build rows whose correlation
scope and residual filter keep that NULL, recorded per build row in a
null-indices bitmap. Candidates come from a scope-map lookup when there are
correlation keys and from a cross product otherwise, then pass the filter. The
cost is proportional to the number of NULLs and is zero when the data has
none; build rows already marked UNKNOWN are skipped.

Null-aware `LeftAnti` also accepts more than one join key now, which the
equality-correlated shape needs; `RightAnti` still requires exactly one.

Flips the affected expectations from the preceding commit, and the Q05-Q07
correctness canaries.

Closes apache#25336

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Decorrelation asked for null-aware semantics only for some `NOT IN` mark
joins. A non-equality correlation leaves a residual join filter rather than an
equi-join key, and that shape was planned without it, so a NULL on the
subquery side read as FALSE instead of UNKNOWN:

    SELECT id FROM oc
    WHERE oc.h > 1 OR oc.id NOT IN (SELECT i.id FROM ic i WHERE i.k < oc.g);

returned an extra row. The preceding commit gives the executor the machinery;
this asks for it in the remaining place.

A mark join needs it only where a NULL mark can behave differently from a
FALSE mark. `AND`/`OR` give TRUE only from TRUE, and a `Filter` keeps a row
only when the predicate is TRUE, so a non-negated `IN` reached through nothing
but `AND`/`OR` is identical with a FALSE mark and must stay on the plain join:
requesting it there cost 1-2 ms -> 7.4 s at 100k x 100k.

That test is made per subquery occurrence, in a recursion that only knows
`AND`/`OR`. The permissive outcome lives in one arm whose pattern is its own
proof, and every other expression, including future `Expr` variants, takes the
null-aware branch. Deciding it once per `WHERE` conjunct instead let a
sibling's shape change an unrelated subquery's join, and needed a second
enumeration of truth contexts that missed `InSubquery.expr`.

Also: a constant `IN` value is projected as an outer column so the equality
becomes `on[0]`, the key position the executor reads as the `NOT IN` value
key; and `build_join` computes `null_aware` once rather than re-deriving it
for the projection, the mark branch and the anti branch.

Flips the remaining expectations from the first commit, and the Q08 canary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Sep 21, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.01102% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.42%. Comparing base (1e09a2a) to head (1c70e49).

Files with missing lines Patch % Lines
...fusion/physical-plan/src/joins/hash_join/stream.rs 93.40% 6 Missing and 7 partials ⚠️
...on/optimizer/src/decorrelate_predicate_subquery.rs 86.07% 3 Missing and 8 partials ⚠️
...tafusion/physical-plan/src/joins/hash_join/exec.rs 94.18% 1 Missing and 4 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #25560    +/-   ##
========================================
  Coverage   82.42%   82.42%            
========================================
  Files        1138     1138            
  Lines      435429   435588   +159     
  Branches   435429   435588   +159     
========================================
+ Hits       358889   359051   +162     
- Misses      54839    54845     +6     
+ Partials    21701    21692     -9     

☔ 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

2 participants