Skip to content

feat: add ASOF join physical operator - #23828

Open
Xuanwo wants to merge 7 commits into
apache:mainfrom
Xuanwo:xuanwo/asof-physical
Open

feat: add ASOF join physical operator#23828
Xuanwo wants to merge 7 commits into
apache:mainfrom
Xuanwo:xuanwo/asof-physical

Conversation

@Xuanwo

@Xuanwo Xuanwo commented Jul 23, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

This is the first layer of the ASOF JOIN stack. It establishes a
broadcast-based physical execution contract independently so later logical-plan,
SQL, DataFrame, and serialization changes can be reviewed as smaller follow-up
PRs.

The initial implementation deliberately favors the simpler broadcast design:
the right input must fit in memory and each left partition scans the shared
right-side batches. A repartitioned implementation can be evaluated separately
without changing the ASOF semantics introduced here.

What changes are included in this PR?

  • Add AsOfJoinExec for left-preserving, Snowflake-style ASOF semantics.
  • Coalesce and collect the ordered right input once, then share it across all
    left partitions.
  • Keep the left input partitioned so each partition can scan independently and
    preserve the left-side output partitioning.
  • Preserve merge state across input and output batch boundaries.
  • Reserve each retained Arrow buffer exactly once, including when right-side
    batches are zero-copy slices, and expose build, match, and output metrics.
  • Define output properties and statistics for the broadcast execution model.
  • Add physical operator tests covering match directions, equality groups,
    batch boundaries, unmatched rows, invalid contracts, shared-buffer memory
    accounting, and multi-partition broadcast execution.

Are these changes tested?

Yes:

  • cargo fmt --all
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test -p datafusion-physical-plan joins::asof_join --all-features
  • Extended workspace tests from the contributor guide

Are there any user-facing changes?

This adds a new physical operator API. SQL and DataFrame APIs are intentionally
left to dependent PRs in the ASOF JOIN stack.

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

codecov-commenter commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.91167% with 204 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.03%. Comparing base (e8a65f2) to head (a127baa).
⚠️ Report is 121 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-plan/src/joins/asof_join.rs 83.91% 140 Missing and 64 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23828      +/-   ##
==========================================
+ Coverage   80.66%   81.03%   +0.36%     
==========================================
  Files        1095     1107      +12     
  Lines      372294   381960    +9666     
  Branches   372294   381960    +9666     
==========================================
+ Hits       300324   309526    +9202     
- Misses      54055    54143      +88     
- Partials    17915    18291     +376     

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

@2010YOUY01 2010YOUY01 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.

Thank you! This is a really good start. I have done a quick first pass and left some suggestions.

Comment thread datafusion/physical-plan/src/joins/asof_join.rs
Comment thread datafusion/physical-plan/src/joins/asof_join.rs
Comment thread datafusion/physical-plan/src/joins/asof_join.rs Outdated
Comment thread datafusion/physical-plan/src/joins/asof_join.rs
Comment thread datafusion/physical-plan/src/joins/asof_join.rs
vec![ChildStats::At(partition), ChildStats::Skip]
}

fn statistics_from_inputs(

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.

Just an idea to make this PR smaller, could we use the default implementation here? We could implement it later in a follow-up PR.

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.

I kept this small override because ASOF has two exact facts the default would discard: the output row count equals the left row count, and unmodified left columns retain their statistics. Right-side column statistics remain unknown. I added a comment to make that scope explicit.

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.

I see, this makes sense.

Comment thread datafusion/physical-plan/src/joins/asof_join.rs Outdated
Comment thread datafusion/physical-plan/src/joins/asof_join.rs
Comment thread datafusion/physical-plan/src/joins/asof_join.rs
Comment thread datafusion/physical-plan/src/joins/asof_join.rs
@Xuanwo
Xuanwo marked this pull request as ready for review August 3, 2026 04:13
@Xuanwo
Xuanwo requested a review from 2010YOUY01 August 3, 2026 04:14
@2010YOUY01

Copy link
Copy Markdown
Contributor

There is no new commits after the previous review, you may have forgotten to push the local changes 🤔 @Xuanwo

@Xuanwo

Xuanwo commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

There is no new commits after the previous review, you may have forgotten to push the local changes 🤔 @Xuanwo

Oh, sorry, latest commit is a127baa

@2010YOUY01 2010YOUY01 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.

I went over the implementation in detail, and I think it's well organized.

Need to do before merging

Before merging, I think we could add a few basic tests that run the executor and assert the results, just to cover some different cases:

  • No equality condition in on.
  • Different comparison operators in the match condition, such as < and >=.
  • Complex expressions in the on or match conditions, such as MATCH_CONDITION (l_c1 + l_c2) > r_c1 or ON l_c1 = (r_c1 + r_c2). I think these should be supported.

Optional suggestions

The main implementation/design questions I have are:

  1. We need to buffer batch_size output rows before emitting them, now it's implemented through the PendingRows struct, so we need to buffer all source batches to produce the final output. I think this uses more memory and makes the implementation more complex. An alternative is a) keep a in_progress_batch, and emit it after it reaches threshould b) only buffer one (left_batch, right_batch), and materialize its valid indices into the in_progress_batch when the cursor moves across it. This might be fast enough and simpler.
  2. Each loop iteration only advances the right index by one. We can probably explore some fast-forwarding optimization here.

But I suggest we first implement this end to end, including planning, SQL support, more tests, and benchmarks, before exploring these optimizations further. It should be easier to validate the ideas afterward.

For now, I only suggest trying to simplify the existing implementation or adding more documentation to make future iterations easier. I left a few suggestions in the comments.

/// rows in the same group; left EOF flushes the final pending rows. NULL keys
/// and group changes clear the candidate, while output flushes only clear
/// pending row references.
async fn next_batch(&mut self) -> Result<Option<RecordBatch>> {

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.

I recommend to add a pseudocode in the comment for the algorithm implemented in this function.

Besides, since this is the major loop, I think we can also point to this function from execute() and top doc comments, like 'the key state machine entry point is the next_batch() function'

on: JoinOn,
match_condition: AsOfMatchExpr,
/// Sorted, unique indices of right columns appended after all left columns.
right_output_indices: Vec<usize>,

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.

I think this left input is also possible to get projected, we could instead use the existing convention for projection:

projection: Option<ProjectionRef>,


fn input_distribution_requirements(&self) -> InputDistributionRequirements {
InputDistributionRequirements::new(vec![
Distribution::UnspecifiedDistribution,

@2010YOUY01 2010YOUY01 Aug 10, 2026

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.

Is it the case the optimizer will insert round-robin repartition, if we declare this UnspecifiedDistribution? It should be fine if it's doing so, I was a little bit confused by this name 🤔

vec![ChildStats::At(partition), ChildStats::Skip]
}

fn statistics_from_inputs(

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.

I see, this makes sense.

}

#[derive(Clone)]
struct Candidate {

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.

We could add some comments to this struct.

Optional suggestion: I think we could put it inside the right InputCursor to simplify the implementation.

Logically, this represents the "previous valid row," and it seems more like an internal implementation detail of InputCursor than a standalone module. I imagine the state machine code would be easier to understand if we structured it this way.

}

#[tokio::test]
async fn broadcasts_right_input_to_all_left_partitions() -> 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.

I think this test don't have a meaningful test goal right now, and it adds maintenance overhead.

It would be better to test it by asserting the plan shape, after we have SQL integration. And I think we could remove it now.

Ok(())
}

#[tokio::test]

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.

Suggested change
#[tokio::test]
// Ensure the build-side memory usage equals the sum of all build-side input
// batches, verifying that the build-side buffer is shared.
#[tokio::test]

}

#[tokio::test]
async fn preserves_dictionary_outputs_across_large_flush() -> 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.

I don't know if we should make such guarantee 🤔

}

#[test]
fn properties_and_statistics_follow_left_preserving_contract() -> 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.

Probably it's not necessary to assert the distribution and ordering properties here

.iter()
})
.collect::<Vec<_>>();
assert_eq!(

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.

Here is a reference to write assertions for similar tests easier:

allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants