Skip to content

fix: handle dictionary nulls in null-aware left mark joins - #25693

Merged
viirya merged 3 commits into
apache:mainfrom
viirya:codex/fix-left-mark-dictionary-nulls
Oct 10, 2026
Merged

viirya merged 3 commits into
apache:mainfrom
viirya:codex/fix-left-mark-dictionary-nulls

Conversation

@viirya

@viirya viirya commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

Null-aware LeftMark joins use physical null checks for dictionary keys. They miss valid dictionary keys that point to NULL dictionary values and return FALSE marks where SQL three-valued logic requires NULL.

What changes are included in this PR?

Null-aware LeftMark joins now use Arrow's logical-null APIs on both probe and build keys. The build-side logical-null bitmap is computed once during build collection, retained with memory accounting, and reused by final output chunks. Probe-side null summaries are computed once per input batch even when hash lookup resumes across several chunks. Correlated joins continue using their precomputed per-row null bitmap.

What is the testing strategy for this PR?

The diff adds three HashJoinExec test functions covering logical dictionary nulls on the build and probe sides, including nested dictionary build keys. #[apply(hash_join_exec_configs)] expands each function across five batch sizes, so Cargo reports 15 new test instances. The probe-side function also checks both probe-partition completion orders inside each instance.

Ablation testing confirmed that the original build- and probe-side functions fail at every batch size without the correctness fix. With the fix, all 109 null-aware tests and the complete 2,409-test datafusion-physical-plan all-features suite pass.

For 524,288-row debug diagnostics, bounding the original flat-dictionary scan reduced runtime from about 693ms to 141ms. The follow-up nested-dictionary diagnostic dropped from about 561ms to 177ms, and the oversized resumed-probe diagnostic dropped from about 1.29s to 910ms.

Are there any user-facing changes?

Null-aware mark joins over dictionary-encoded keys now return the correct nullable marks. There are no public API changes.

@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Sep 24, 2026
@codecov-commenter

codecov-commenter commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.24561% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.73%. Comparing base (1c43063) to head (b764107).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...tafusion/physical-plan/src/joins/hash_join/exec.rs 97.61% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #25693   +/-   ##
=======================================
  Coverage   82.73%   82.73%           
=======================================
  Files        1147     1147           
  Lines      449389   449437   +48     
  Branches   449389   449437   +48     
=======================================
+ Hits       371788   371834   +46     
- Misses      54943    54947    +4     
+ Partials    22658    22656    -2     

☔ 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 a review from sunchao September 24, 2026 16:03
@viirya

viirya commented Sep 26, 2026

Copy link
Copy Markdown
Member Author

@sunchao Can you take a look? Thanks!

@viirya
viirya requested a review from alamb September 30, 2026 17:41
@viirya

viirya commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

@sunchao @alamb Can you take a look? Thanks!

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @viirya. The logical-null changes fix both reported wrong-result paths. I ran all 100 matching null-aware join tests on d3ccef0; they passed. Applying only the new tests to parent 2306a4b produced the expected 10 failures, with the 20 pre-existing mark cases passing. Current-head CI has 40 successful and 3 skipped checks.

I found one introduced performance issue in final mark emission, detailed inline. Please bound the logical-null computation to the current output chunk or compute it once for the join before approval.

probe_side_has_null: bool,
probe_side_non_empty: bool,
) -> ArrayRef {
let build_key_nulls = build_key_column.logical_nulls();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Avoid rescanning the full build dictionary for every output batch

emit_unmatched_build_rows calls this helper once per batch_size chunk, but build_key_column is the entire concatenated build column. In Arrow 60, DictionaryArray::logical_nulls() allocates an N-bit bitmap and walks all N keys when the dictionary values contain NULL. This makes final emission do N * ceil(N / batch_size) key inspections. It also runs for correlated joins even though null_indices_bitmap supplies the answer and the new buffer is unused.

I reproduced this with a null-aware CollectLeft LeftMark: dictionary<Int32, Int32> build values [1, NULL], alternating keys [0, 1, ...], an empty probe, and batch size 8192. Both revisions return exactly N non-null FALSE marks. With separate base/head target directories and the locked Arrow 60 dependencies, median times over three unoptimized operator runs were 0.185s vs 1.165s for 524,288 rows, and 0.369s vs 4.274s for 1,048,576 rows. Reversing run order retained the smaller-case slowdown; the equivalent plain Int32 control stayed comparable. These are diagnostic debug-build timings, not release query benchmarks.

Could we compute logical nulls only for the current contiguous LeftMark chunk (adjusting the index), or retain the bitmap once for final emission, and skip this work when the correlated bitmap is present? That preserves the fix without repeatedly scanning the full build side.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is valid. I reproduced the repeated full-dictionary scan with 524,288 rows and batch size 8192. The diagnostic runtime dropped from about 693ms to 141ms after limiting logical_nulls() to the current contiguous output chunk. Correlated joins now skip this computation and continue using their precomputed bitmap. All 104 null-aware tests and the complete datafusion-physical-plan all-features suite pass.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @viirya. I confirmed that 4d50daa fixes the original flat-dictionary case: the same 524,288-row diagnostic improved from a median 1.152s on d3ccef0 to 0.209s on the new head.

One part of this P2 remains for nested dictionary build keys at the new slice/logical_nulls call. Slicing a DictionaryArray only slices its keys and retains all its values. When those values are themselves a dictionary, Arrow 60 recursively materializes the entire inner dictionary's logical-null bitmap on every output chunk. The outer slice therefore does not bound that inner scan.

I verified a nonempty nested-dictionary join executes and produces the expected TRUE/NULL/FALSE marks. To isolate final emission, I then used an empty probe and Dictionary<Int32, Dictionary<Int32, Int32>> build keys: outer keys 0..N, N inner keys all 0, leaf values [1, NULL, 4], batch size 8192. Both pre-PR base 2306a4b and current head produce exactly N non-null FALSE marks. At 524,288 rows, medians were 0.234s on base versus 0.954s on head; at 1,048,576 rows, 0.471s versus 3.307s. These are three-run unoptimized native-operator diagnostics with identical compiler/locked dependencies and separate target directories, not release SQL benchmarks.

Could we compute logical nulls once on entry to final emission and slice that bitmap for each chunk, accounting for any retained allocation? That avoids repeatedly traversing nested dictionary values as well as the flat keys.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, confirmed. Slicing the outer dictionary retains its full child dictionaries, so chunk-local logical_nulls still rescanned nested values. Fixed in 3bc2308 by computing build-key logical nulls once during build collection, retaining only a non-empty bitmap, accounting for newly materialized buffer memory, and indexing the cached bitmap by the original build row during final emission. The 524,288-row nested-dictionary diagnostic dropped from about 561ms to 177ms locally. I also added nested-dictionary correctness coverage across five batch sizes; all 109 null-aware tests and the complete 2,409-test datafusion-physical-plan all-features suite pass.

@viirya
viirya requested a review from sunchao October 6, 2026 06:52

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @viirya. Re-reviewed 4d50daa with independent source passes and fresh runtime checks. The flat-dictionary build scan from my earlier review is fixed, and the chunk-local null indexing and correlated bypass look correct. All 100 matching null-aware tests pass; current-head CI has 40 successful and 3 skipped checks.

Two performance issues remain: the nested-dictionary part of final emission (updated in the existing thread), and repeated whole-probe logical-null scans on lookup resumption (inline below). Both were reproduced against the pre-PR base with identical result assertions. Please address these before approval. Timings in the comments are scoped to unoptimized native-operator diagnostics.

} else {
probe_key_column.null_count() > 0
};
let probe_has_null = probe_key_column.logical_null_count() > 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Record probe logical nulls once per input batch

process_probe_batch calls this on every resumed lookup chunk, not just when a probe batch is first fetched. With dictionary values containing a NULL, Arrow 60's logical_null_count() walks every key in the whole probe batch, even if that NULL dictionary entry is unused. This change therefore adds another full probe-key scan for every lookup chunk.

I reproduced the slowdown with one build value 1 and one native probe batch of 524,288 dictionary keys all 0, dictionary values [1, NULL], and join batch size 8192. Both pre-PR base 2306a4b and head 4d50daa return exactly one TRUE mark. Median unoptimized operator time over three runs increased from 1.610s to 2.348s; the plain Int32 control stayed about 0.057s. Compiler, locked Arrow 60 dependencies, and fixture were identical, with separate target directories. This oversized-batch case is supported through native/custom execution plans; normal DataSourceExec scans split batches, so these are not ordinary SQL performance claims.

Please compute and record the probe summary only on the first chunk (state.offset == (0, None)), as the adjacent correlated bookkeeping already does. Keep the anti-join's shared NULL-hint check outside that guard so another partition can still trigger early exit between chunks.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, confirmed. Fixed in 3bc2308 by computing and recording the probe logical-null summary only when state.offset is the initial (0, None). The shared NULL-hint check for LeftAnti remains outside that guard, so another partition can still stop resumed lookup early. The 524,288-row oversized-probe diagnostic dropped from about 1.29s to 910ms with identical result assertions. All 109 null-aware tests and the complete datafusion-physical-plan all-features suite pass.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @viirya. Re-reviewed 3bc2308 with independent source passes and fresh execution checks. Both performance findings from my previous review are addressed: build logical nulls are computed once with retained-buffer accounting, and probe summaries are recorded only on the first lookup chunk while the shared anti-join NULL hint remains checked on resumed chunks. I found no new actionable issue.

Validation on this head:

  • All 2,340 datafusion-physical-plan library tests passed with locked dependencies.
  • Re-ran the flat/nested dictionary and oversized-probe reproducers, including exact mark assertions and plain Int32 controls. The mixed nested-dictionary case passes here and fails on the pre-PR base with the expected FALSE-versus-NULL mismatch.
  • The empty-probe nested-dictionary diagnostic now takes median 0.264s at 524,288 rows and 0.516s at 1,048,576 rows, without the previous repeated-child-scan growth. The 524,288-row oversized dictionary probe takes 1.625s on both this head and pre-PR base 2306a4b. These are three-run unoptimized native-operator diagnostics using the same Rust 1.98.1 compiler, locked Arrow 60 dependencies, and separate target directories, not release SQL benchmarks.

LGTM for the reviewed code. GitHub still reports merge conflicts, and this head has only two Process checks (one successful, one cancelled), not a substantive test-CI result. Please rebase and obtain current-head CI before merging.

@viirya
viirya force-pushed the codex/fix-left-mark-dictionary-nulls branch from 3bc2308 to b764107 Compare October 7, 2026 19:24
@viirya
viirya added this pull request to the merge queue Oct 10, 2026
@viirya

viirya commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Thank you @sunchao !

Merged via the queue into apache:main with commit a4b8198 Oct 10, 2026
42 checks passed
@viirya
viirya deleted the codex/fix-left-mark-dictionary-nulls branch October 10, 2026 22:44
Omega359 pushed a commit to Omega359/arrow-datafusion that referenced this pull request Oct 11, 2026
)

## Which issue does this PR close?

- Closes apache#25691.

## Rationale for this change

Null-aware `LeftMark` joins use physical null checks for dictionary
keys. They miss valid dictionary keys that point to `NULL` dictionary
values and return `FALSE` marks where SQL three-valued logic requires
`NULL`.

## What changes are included in this PR?

Null-aware `LeftMark` joins now use Arrow's logical-null APIs on both
probe and build keys. The build-side logical-null bitmap is computed
once during build collection, retained with memory accounting, and
reused by final output chunks. Probe-side null summaries are computed
once per input batch even when hash lookup resumes across several
chunks. Correlated joins continue using their precomputed per-row null
bitmap.

## What is the testing strategy for this PR?

The diff adds three `HashJoinExec` test functions covering logical
dictionary nulls on the build and probe sides, including nested
dictionary build keys. `#[apply(hash_join_exec_configs)]` expands each
function across five batch sizes, so Cargo reports 15 new test
instances. The probe-side function also checks both probe-partition
completion orders inside each instance.

Ablation testing confirmed that the original build- and probe-side
functions fail at every batch size without the correctness fix. With the
fix, all 109 null-aware tests and the complete 2,409-test
`datafusion-physical-plan` all-features suite pass.

For 524,288-row debug diagnostics, bounding the original flat-dictionary
scan reduced runtime from about 693ms to 141ms. The follow-up
nested-dictionary diagnostic dropped from about 561ms to 177ms, and the
oversized resumed-probe diagnostic dropped from about 1.29s to 910ms.

## Are there any user-facing changes?

Null-aware mark joins over dictionary-encoded keys now return the
correct nullable marks. There are no public API changes.
joroKr21 pushed a commit to coralogix/arrow-datafusion that referenced this pull request Oct 23, 2026
…ins (apache#26578)

## Which issue does this PR close?

- Closes apache#26568.

## Rationale for this change

`x NOT IN (subquery)` keeps a build row whose key is a dictionary entry
pointing at a NULL value. `NULL NOT IN (5, 6)` is UNKNOWN, so the row
should be dropped, as it is when the key is a physical NULL.

## What changes are included in this PR?

The final step of the uncorrelated null-aware `LeftAnti` join now checks
the logical nulls of the build key instead of `is_null`. They are
computed once with the build side, reusing what apache#25693 added for
`LeftMark`, so the field and its getter are renamed to
`null_aware_build_key_nulls`.

## What is the testing strategy for this PR?

New `test_null_aware_left_anti_build_logical_null` with the case from
the issue. It fails without the fix.

## Are there any user-facing changes?

No.

Co-authored-by: Alejandro Borrego <210245044+abp002@users.noreply.github.com>
Co-authored-by: Jay Zhan <jayzhan211@gmail.com>
jayzhan211 added a commit to cning112/datafusion that referenced this pull request Oct 24, 2026
## Which issue does this PR close?

- None.

## Rationale for this change

`HashJoinExec` supports SQL `NOT IN` through `null_aware = true`, in
three forms: `LeftAnti`, `LeftMark` and `RightAnti`, each correlated or
not. The rules for which rows such a join emits are spread out today:

- `exec.rs`: `NullAwareMode`, four `JoinLeftData` fields and one
`JoinBuildData` field that only some forms use, and the correlated
per-row state built inside the build function.
- `stream.rs`: about 700 lines inside the regular join stream. These are
the batch-skip decision, the correlated marking pass and its helpers,
the RightAnti NULL and UNKNOWN drops, and two final-stage functions.
- `utils.rs`: a second copy of the final rule, used for the LeftMark
column.

The two copies of the final rule already drifted once: LeftMark checked
logical NULLs since apache#25693, LeftAnti physical ones until apache#26578.

This code changes often. Since July it gained four features (apache#23957,
apache#21585, apache#19857, apache#26213) and seven correctness fixes (apache#23104, apache#23173,
apache#25076, apache#25339, apache#25693, apache#26586, apache#26578), and apache#26214, apache#25679 and apache#26088
propose more. Each such change has to find the `NOT IN` rules in three
files, usually about six functions, interleaved with the regular join. A
change to the regular probe loop has to read past them too.

This PR moves the rules into `hash_join/not_in.rs`, with one copy of the
final rule, so a `NOT IN` change touches one module and a regular-join
change doesn't need to read it. The join reaches it at four points:

1. `NotIn::try_new` decides when the plan is built or executed whether
`null_aware` is legal for the join, and which form it is: `BuildRows {
scope, output: Rows | Mark }` for LeftAnti and LeftMark, or `ProbeRows {
scope }` for RightAnti, where `scope` is `Uncorrelated` or `Correlated`.
2. `NotInBuild::try_new` runs once per build. It has one variant per
form, holding only that form's state. `JoinLeftData` keeps a single
`Option<NotInBuild>`.
3. `NotInProbe::before_lookup` / `after_lookup` run around each probe
batch's lookup, and `after_empty_build` replaces `after_lookup` when the
build map is empty. They skip the batch, record what the probe saw, mark
build rows or this batch's probe rows UNKNOWN, and drop the probe rows a
RightAnti must not emit; for other joins they do nothing.
4. `NotInBuild::final_chunk` runs on the build rows emitted at the end.
It applies one rule for an unmatched row's mark, used both by LeftAnti
(keep the rows whose mark is FALSE) and by LeftMark (the mark column).

## What changes are included in this PR?

Six commits, each compiling and passing clippy on its own:

1. **Move the correlated marking helpers into `not_in.rs`.** The code is
unchanged, apart from one signature that now takes the build data and
probe batch instead of the stream's private state structs. This commit
also adds a test for correlated marking when every build value key is
NULL. The full-key map is empty then, and the marking must still run
before the empty-map shortcut; nothing covered this before.
2. **`NotIn` replaces `NullAwareMode`.** The legality checks, messages
and order are the same.
3. **`NotInBuild` holds the per-form build state, with one final rule.**
It replaces the `JoinLeftData` / `JoinBuildData` fields, the two
final-stage functions in `stream.rs`, and
`build_null_aware_left_mark_column` in `utils.rs`.
4. **The `NotInProbe` hooks replace the stream's `NOT IN` branches and
fields.** The correlated RightAnti's per-batch UNKNOWN probe rows move
from `ProcessProbeBatchState` into `NotInProbe`, which recomputes them
on each batch's first lookup.
5. **Review follow-ups.** `HashJoinExec::not_in()` and the `not_in`
parameters replace `null_aware_mode()` and `null_aware: Option<NotIn>`,
the helpers used only inside `not_in.rs` become private, and
`final_chunk` asserts that a probe side with a NULL key is not empty.
6. **Test the correlated RightAnti empty-build path.**
`test_null_aware_correlated_all_null_build_values` also runs RightAnti,
which must drop the UNKNOWN probe rows when the build map is empty
(`NotInProbe::after_empty_build`).

Types: `NullAwareMode` goes, and `NotIn`, `NotInScope`, `NotInOutput`,
`NotInBuild` and `NotInProbe` come in. Gone: the `NOT IN` fields of
`JoinLeftData`, `JoinBuildData`, `HashJoinStream` and
`ProcessProbeBatchState` and their constructor parameters, 4 stream
functions (`null_aware_skip_probe_batch`, `drop_null_probe_keys`,
`null_aware_left_anti_final_indices`, `null_aware_left_mark_column`),
and the second copy of the final rule.

What does not change:
- the planner rules, including the `JoinSelection` swap to RightAnti;
- the dynamic-filter gate for null-aware joins;
- the prefilter and prepared-build exclusions;
- the cross-partition `ProbeCompletion`.

### Behavior notes

No query results change. Two internal orders do:

- **Reservation order.** For an uncorrelated LeftAnti or LeftMark join
whose build key has newly materialized logical NULLs (a dictionary, REE
or Null key), the NULL buffer is now charged to the memory reservation
right after the visited bitmap. On main it was charged after the
membership step's optional pruning bitmap. Under a tight memory limit, a
different allocation may be the one that fails. The pruning bitmap only
exists with a dynamic filter, which a null-aware join with a nullable
build key doesn't get.
- **The form is computed twice per execution.** The stream reads it from
the build (`JoinLeftData::not_in`); `execute` derives it again
(`HashJoinExec::not_in()`) for the dynamic filter. Main derived it three
times (build, stream, accumulator). The two can only disagree if an
executed plan is rebuilt and run again without `reset_state`, which is
already unsupported. Main panicked in that case; this PR uses the
build's form.

## Performance

Benchmark bot (GKE `c4a-highmem-16`, aarch64) on `a9d4896d7e` against
its merge base `702803d022`; the later commit only adds a test:

- `null_aware_join`: geomean 0.989. Q01 and Q03 are 4% faster, Q02, Q05
and Q06 about 2% faster, Q08 2% slower (20.4 → 20.8 ms), the rest are
within 1%.
- `hj`: total 6870 → 6885 ms (+0.2%). Q22 and Q24 are unchanged. The
queries the bot flags (Q11, Q14, Q15, Q17 slower; Q16, Q18 faster) move
within their run-to-run spread.
- `tpch10`: all 22 queries unchanged, total 7078 → 7049 ms.

Earlier runs on an M-series Mac showed this refactor slowing `hj` Q24 by
up to 9% through different LLVM inlining of the probe loop. That doesn't
reproduce on the bot, so the PR leaves inlining to the compiler. Not
measured on x86_64.

## What is the testing strategy for this PR?

- New test: `test_null_aware_correlated_all_null_build_values`, covering
LeftAnti, LeftMark and RightAnti with the probe in two batches, at every
batch-size config.
- Existing tests:
  - physical-plan unit tests;
- the null-aware join fuzz test (`null_aware_join_fuzz`, with
`extended_tests`), which covers uncorrelated and correlated RightAnti
plans after the `JoinSelection` swap;
- the `null_aware_*` and `subquery*` sqllogictest files, with no changes
to expected results or `EXPLAIN` output.

## Are there any user-facing changes?

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

Labels

physical-plan Changes to the physical-plan crate v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Null-aware LeftMark joins miss logical NULLs in dictionary keys

3 participants