Skip to content

fix: route sort-merge semi/anti/mark key equality through JoinKeyComparator - #26006

Closed
patrickswedish wants to merge 3 commits into
apache:mainfrom
patrickswedish:fix/sort-merge-join-key-comparator-types
Closed

patrickswedish wants to merge 3 commits into
apache:mainfrom
patrickswedish:fix/sort-merge-join-key-comparator-types

Conversation

@patrickswedish

@patrickswedish patrickswedish commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #26003.

Rationale for this change

In BitwiseSortMergeJoinStream (which powers sort-merge semi, anti, and mark joins), when a key group spans across batch boundaries, keys_match verifies key equality by calling compare_join_arrays.

Prior to this change:

  1. compare_join_arrays performed a manual downcast across a restricted subset of primitive Arrow types and returned not_impl_err!("Unsupported data type in sort merge join comparator: {}", dt) for other data types such as:
    • Timezone-aware timestamps (DataType::Timestamp(_, Some(_)))
    • Dictionary-encoded keys (DataType::Dictionary(..))
    • DataType::Decimal256(..)
    • Time keys (DataType::Time32(..), DataType::Time64(..))
    • Duration and Interval keys
  2. As a consequence, queries performing semi, anti, or mark sort-merge joins over timezone-aware timestamps or dictionary keys failed at runtime during batch continuation with an "Unsupported data type" error.
  3. In addition, keys_match previously contained an ad-hoc branch for floating-point arrays to handle IEEE-754 -0.0 normalization and NaN comparisons via JoinKeyComparator, while other types fell back to compare_join_arrays.

What changes are included in this PR?

  1. Route compare_join_arrays directly to JoinKeyComparator:
    • Replaced the manual match table in compare_join_arrays with JoinKeyComparator::new(left_arrays, right_arrays, sort_options, null_equality)?.compare(left, right).
    • JoinKeyComparator builds Arrow's make_comparator closures once per batch pair, providing support for all Arrow data types, preserving IEEE-754 -0.0 normalization, and respecting NullEquality modes consistently across all join streams.
  2. Clean up keys_match in bitwise_stream.rs:
    • Slices the first row of right_arrays and evaluates JoinKeyComparator::new(left_arrays, &right_keys, sort_options, null_equality)?.is_equal(0, 0).
    • Eliminates the special-cased float branch in keys_match since all types are now handled uniformly.
  3. Tests:
    • Added unit test test_compare_join_arrays_extended_types in joins::utils::tests validating comparisons across timezone-aware timestamps (TimestampMicrosecondArray with "UTC"), dictionary arrays (Dictionary(Int32, Utf8)), Decimal256Array, Time64NanosecondArray, and Time32MillisecondArray.

Are these changes tested?

Yes:

  • New unit test test_compare_join_arrays_extended_types added in datafusion/physical-plan/src/joins/utils.rs.
  • All 247 existing sort-merge join tests pass (cargo test --package datafusion-physical-plan --lib joins::sort_merge_join::tests).
  • Formatted with rustfmt and verified with cargo clippy --package datafusion-physical-plan --lib -- -D warnings (0 warnings).

Are there any user-facing changes?

No breaking changes. Users can now execute sort-merge semi, anti, and mark joins over timezone-aware timestamps, dictionary keys, Decimal256, and time keys without errors.

…arator

Sort-merge semi, anti, and mark joins (BitwiseSortMergeJoinStream) failed on timezone-aware timestamp, Dictionary, Decimal256, and Time keys with 'Unsupported data type in sort merge join comparator'.

When a key group crosses batch boundaries, keys_match checks for key equality by calling compare_join_arrays. That function manually matched against a restricted subset of Arrow types and returned not_impl_err for everything else.

Route compare_join_arrays and keys_match through JoinKeyComparator, which builds underlying Arrow make_comparator closures once per batch pair supporting all Arrow data types, preserving IEEE-754 -0.0 normalization and handling NullEquality modes consistently across all join streams.

Closes apache#26003
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Oct 3, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.15596% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.64%. Comparing base (95a1961) to head (d17ad3f).

Files with missing lines Patch % Lines
datafusion/physical-plan/src/joins/utils.rs 87.61% 10 Missing and 3 partials ⚠️
...l-plan/src/joins/sort_merge_join/bitwise_stream.rs 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #26006   +/-   ##
=======================================
  Coverage   82.64%   82.64%           
=======================================
  Files        1147     1147           
  Lines      445502   445555   +53     
  Branches   445502   445555   +53     
=======================================
+ Hits       368179   368231   +52     
+ Misses      54969    54960    -9     
- Partials    22354    22364   +10     

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

@kosiew kosiew left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@patrickswedish,

Thanks for this fix. Routing cross-batch key equality through JoinKeyComparator is appropriate and resolves the restricted type support in the previous comparison path. The changes are focused, and I found no correctness regression in the reviewed diff.

The architectural boundary is sound: JoinKeyComparator owns join-key comparison semantics, while the stream handles batch continuation. This keeps type support, NULL handling, and floating-point equality consistent with the merge scan without introducing another abstraction or duplicating comparison policy.

I'm approving because the implementation addresses the reported failure and the added tests cover representative extended types. I left a few non-blocking suggestions to strengthen execution-level and dictionary-encoding regression coverage and reduce test boilerplate.

Ok(cmp == Ordering::Equal)
let right_keys = slice_keys(right_arrays, 0);
let cmp =
JoinKeyComparator::new(left_arrays, &right_keys, sort_options, null_equality)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we add a non-blocking execution-level regression using the existing join_float_key_batch_boundaries harness with timezone-aware timestamp keys? Please cover groups ending and continuing across batch boundaries, asserting semi/anti row IDs and mark bits, since the current tests exercise the helper rather than this stream's continuation path.

);

// 2. Dictionary(Int32, Utf8)
let dict_left: ArrayRef = Arc::new(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we also test equal dictionary values with different codes using independently ordered dictionaries built with DictionaryArray::try_new? The existing banana < cherry check already catches raw-code ordering, but the equal apple values both use code 0, so equality across different encodings remains untested. Please keep the existing inequality assertion.

}

#[test]
fn test_compare_join_arrays_extended_types() -> Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As a non-blocking cleanup, consider collecting the five (name, left, right) array pairs into a small table and running the shared equal/less assertions in a loop, with the case name in assertion messages. This would remove repeated comparator calls and make additional type coverage easier to maintain.

@alamb alamb added the Prolific Author author opened PRs or issues in more than 20 repositories in the last 30 days label Oct 22, 2026
@alamb

alamb commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

This PR appears to be waiting on the author to respond to reviewer feedback so marking as draft. Please mark ready to review when it is ready for another look

@alamb
alamb marked this pull request as draft October 24, 2026 10:04
…egressions

- Cover dictionary keys with independently ordered codes (different index encodings for equal values).
- Consolidate extended type comparator test cases into a structured table loop.
- Add execution-level join_timestamp_tz_key_batch_boundaries regression testing semi, anti, and mark joins across batch boundaries with continuing groups.
@patrickswedish
patrickswedish marked this pull request as ready for review October 24, 2026 14:56
@patrickswedish

patrickswedish commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Thank you @kosiew for the thorough architectural review and approval!

I have addressed all the non-blocking suggestions:

  1. Dictionary Cross-Code Ordering: Updated test_compare_join_arrays_extended_types using DictionaryArray::try_new with independently ordered codes ("apple" at code 0 in left, code 1 in right; "banana" at code 1 in left, "cherry" at code 0 in right) to verify value-based comparison across differing encodings.
  2. Table-Driven Comparator Tests: Consolidated the extended type test cases into a structured table loop with descriptive failure assertions.
  3. Execution-Level Stream Boundary Regression: Added join_timestamp_tz_key_batch_boundaries across 96 parameter combinations covering timezone-aware timestamp keys with duplicate groups continuing across batch boundaries for semi, anti, and mark joins.

All tests pass locally. Marked ready for review @alamb.

}
}
Ok(res)
let cmp =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: nothing in this crate calls compare_join_arrays now, but it's still public. Building the comparator over the full arrays makes every call O(len): normalize_float_zero scans all the float values and logical_nulls scans dictionary keys. Slicing both sides to the one row first keeps it O(1) per call, like before:

-    let cmp =
-        JoinKeyComparator::new(left_arrays, right_arrays, sort_options, null_equality)?;
-    Ok(cmp.compare(left, right))
+    let left_row: Vec<ArrayRef> = left_arrays.iter().map(|a| a.slice(left, 1)).collect();
+    let right_row: Vec<ArrayRef> =
+        right_arrays.iter().map(|a| a.slice(right, 1)).collect();
+    let cmp = JoinKeyComparator::new(&left_row, &right_row, sort_options, null_equality)?;
+    Ok(cmp.compare(0, 0))

…tor initialization

Slice inputs to single-row array views prior to JoinKeyComparator construction so float normalization and dictionary key scanning remain constant-time rather than scanning full array buffers.

Suggested-by: Jay Zhan <@jayzhan211>
@patrickswedish

Copy link
Copy Markdown
Contributor Author

Thank you @jayzhan211 for the thorough review, approval, and the sharp catch on compare_join_arrays!

You are completely right — constructing JoinKeyComparator over the entire array buffers incurred an unnecessary O(N) scan (normalize_float_zero and dictionary key inspection) for what should be a constant-time single-row comparison. Slicing both sides to single-row views (a.slice(..., 1)) preserves strictly O(1) execution per call without full-array normalization overhead.

Applied your exact suggestion in 154624c. All checks and tests passing cleanly and ready for merge!

@alamb alamb left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FWIW @patrickswedish sent my company a threat of litigation related to his contributions to this repository. While we think this has no merit, I would recommend we do not merge this PR or accept changes from this contributor, to avoid future headaches and legal claims

@alamb

alamb commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

@alamb alamb closed this Oct 25, 2026
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 Prolific Author author opened PRs or issues in more than 20 repositories in the last 30 days

Projects

None yet

5 participants