Skip to content

Support co-partitioned range inner equi joins#23184

Merged
comphead merged 1 commit into
apache:mainfrom
gene-bordegaray:gene.bordegaray/2026/06/range-partitioned-joins
Jul 10, 2026
Merged

Support co-partitioned range inner equi joins#23184
comphead merged 1 commit into
apache:mainfrom
gene-bordegaray:gene.bordegaray/2026/06/range-partitioned-joins

Conversation

@gene-bordegaray

@gene-bordegaray gene-bordegaray commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

DataFusion can represent source-declared range partitioning, but partitioned hash joins still required hash partitioned inputs. So an inner join on compatible range-partitioned keys would insert unnecessary hash repartitions, even when each left/right partition already covered the same key domain.

This PR adds a partitioning requirement that means "equal key values are co-located" . I was calling this "compatibility" but found we can satisfy the requirement with looser conditions. Other systems call this "co-location" or "co-partitioning" (trino, spark). Which they (and now I am proposing) define as when both sides of a join are already partitioned so matching key values appear in corresponding partitions, so we can join partition pairs directly without repartitioning the sides.

This lets "co-partitioned" range inputs satisfy inner partitioned hash joins. This will also be applicable to other join types and operators but kept the first PR thin to keep scope more reviewable.

What changes are included in this PR?

  • Adds Distribution::KeyPartitioned(Vec<Arc<dyn PhysicalExpr>>) as a public distribution requirement.

    • HashPartitioned([a]) means rows must be partitioned by hash on a.
    • KeyPartitioned([a]) means rows with equal a values must be co-located, but the partitioning algorithm may be hash, range, or another compatible scheme.
    • Example:
      Hash([left.a], 3) satisfies KeyPartitioned([left.a])
      Range([right.b ASC], [(10), (20)], 3) satisfies KeyPartitioned([right.b])
      
  • Adds Partitioning::co_partitioned_with(...) to validate that two independently satisfying partitionings also can be paired by partition index.

    • Examples:
      • Accepted: both sides satisfy their own key requirement and have matching range boundaries.
        left:  Range([a ASC], [(10), (20)], 3), required KeyPartitioned([a])
        right: Range([b ASC], [(10), (20)], 3), required KeyPartitioned([b])
        
      • Accepted: both sides satisfy their own key requirement and have matching hash partition counts.
        left:  Hash([a], 3), required KeyPartitioned([a])
        right: Hash([b], 3), required KeyPartitioned([b])
        
      • Rejected: both sides satisfy their own key requirement, but range boundaries differ.
        left:  Range([a ASC], [(10), (20)], 3), required KeyPartitioned([a])
        right: Range([b ASC], [(15), (20)], 3), required KeyPartitioned([b])
        
      • Rejected: both sides satisfy their own key requirement, but partition counts differ.
        left:  Hash([a], 3), required KeyPartitioned([a])
        right: Hash([b], 4), required KeyPartitioned([b])
        
  • Changes inner partitioned HashJoinExec requirements from HashPartitioned to KeyPartitioned.

    • All other hash joins still require HashPartitioned for now.
  • Updates EnforceDistribution so co-partitioned range inner joins avoid repartitioning.

    • Examples:
      • Compatible range partitioning: no repartition is inserted because partitions can be joined by index.
        HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a, b)]
          DataSourceExec: output_partitioning=Range([a ASC], [(10), (20)], 3)
          DataSourceExec: output_partitioning=Range([b ASC], [(10), (20)], 3)
        
      • Incompatible range boundaries: both sides are repartitioned by hash because partition i does not represent the same key domain on both sides.
        HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a, b)]
          RepartitionExec: partitioning=Hash([a], target_partitions)
            DataSourceExec: output_partitioning=Range([a ASC], [(10), (20)], 3)
          RepartitionExec: partitioning=Hash([b], target_partitions)
            DataSourceExec: output_partitioning=Range([b ASC], [(15), (20)], 3)
        
      • Mismatched hash partition counts: both sides are forced to the target hash partition count so partition indexes line up.
        HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a, b)]
          RepartitionExec: partitioning=Hash([a], target_partitions)
            DataSourceExec: output_partitioning=Hash([a], 11)
          RepartitionExec: partitioning=Hash([b], target_partitions)
            DataSourceExec: output_partitioning=Hash([b], 12)
        
      • Non-inner joins: range inputs still get hash repartitioning because only inner partitioned hash joins use KeyPartitioned in this PR.
        HashJoinExec: mode=Partitioned, join_type=Left, on=[(a, b)]
          RepartitionExec: partitioning=Hash([a], target_partitions)
            DataSourceExec: output_partitioning=Range([a ASC], [(10), (20)], 3)
          RepartitionExec: partitioning=Hash([b], target_partitions)
            DataSourceExec: output_partitioning=Range([b ASC], [(10), (20)], 3)
        
  • Keeps partitioned dynamic filter pushdown restricted to hash-compatible routing.

    • Compatible range partitioning can satisfy the join, but dynamic filters still route by hash, so range/range partitioned joins disable dynamic filters.
  • Degrades range join output partitioning to UnknownPartitioning(n) rather than erroring. Adding this behavior would need more tests and careful thought about, I think its safert o just degrade for first PR.

Are these changes tested?

Yes.

  • KeyPartitioned satisfaction for hash and range partitioning.
  • co_partitioned_with for compatible and incompatible range/hash partitioning.
  • EnforceDistribution behavior for:
    • compatible range joins avoiding hash repartitioning
    • incompatible range bounds rehashing
    • mismatched hash partition counts rehashing
    • non-inner range joins rehashing
  • sanity checking for invalid partitioned hash joins.
  • dynamic filter rejection for range partitioning, preserved file partitions, and mismatched hash counts.
  • sqllogictest coverage for range-partitioned joins avoiding hash repartitioning and non-range joins still repartitioning.

Are there any user-facing changes?

Yes.

This PR changes public physical planning APIs:

  • Adds Distribution::KeyPartitioned.
  • Adds Partitioning::co_partitioned_with.
    • NOTE: This replaces the previous partition compatibility API with the new co-partitioning API. Since the compatibility API was never in a release I believe this is ok to do (lesson learned to not make API change until ew have definitive consumer).
  • Affects users matching exhaustively on Distribution.

@github-actions github-actions Bot added physical-expr Changes to the physical-expr crates optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Jun 25, 2026
@gene-bordegaray gene-bordegaray changed the title Support co-partitioned range hash joins [WIP] Support co-partitioned range hash joins Jun 25, 2026
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v54.0.0 (current)
       Built [  87.166s] (current)
     Parsing datafusion v54.0.0 (current)
      Parsed [   0.029s] (current)
    Building datafusion v54.0.0 (baseline)
       Built [  86.367s] (baseline)
     Parsing datafusion v54.0.0 (baseline)
      Parsed [   0.030s] (baseline)
    Checking datafusion v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.719s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 176.770s] datafusion
    Building datafusion-datasource v54.0.0 (current)
       Built [  30.573s] (current)
     Parsing datafusion-datasource v54.0.0 (current)
      Parsed [   0.028s] (current)
    Building datafusion-datasource v54.0.0 (baseline)
       Built [  29.720s] (baseline)
     Parsing datafusion-datasource v54.0.0 (baseline)
      Parsed [   0.027s] (baseline)
    Checking datafusion-datasource v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.291s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  62.050s] datafusion-datasource
    Building datafusion-physical-expr v54.0.0 (current)
       Built [  22.999s] (current)
     Parsing datafusion-physical-expr v54.0.0 (current)
      Parsed [   0.038s] (current)
    Building datafusion-physical-expr v54.0.0 (baseline)
       Built [  22.518s] (baseline)
     Parsing datafusion-physical-expr v54.0.0 (baseline)
      Parsed [   0.040s] (baseline)
    Checking datafusion-physical-expr v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.412s] 223 checks: 222 pass, 1 fail, 0 warn, 30 skip

--- failure inherent_method_missing: pub method removed or renamed ---

Description:
A publicly-visible method or associated fn is no longer available under its prior name. It may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.48.0/src/lints/inherent_method_missing.ron

Failed in:
  RangePartitioning::compatible_with, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/2fb7dbc2d6a1d5d763340c70b7f3e4a3f36d0d61/datafusion/physical-expr/src/partitioning.rs:253
  Partitioning::compatible_with, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/2fb7dbc2d6a1d5d763340c70b7f3e4a3f36d0d61/datafusion/physical-expr/src/partitioning.rs:416

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  47.395s] datafusion-physical-expr
    Building datafusion-physical-optimizer v54.0.0 (current)
       Built [  30.969s] (current)
     Parsing datafusion-physical-optimizer v54.0.0 (current)
      Parsed [   0.019s] (current)
    Building datafusion-physical-optimizer v54.0.0 (baseline)
       Built [  30.832s] (baseline)
     Parsing datafusion-physical-optimizer v54.0.0 (baseline)
      Parsed [   0.019s] (baseline)
    Checking datafusion-physical-optimizer v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.130s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  63.383s] datafusion-physical-optimizer
    Building datafusion-physical-plan v54.0.0 (current)
       Built [  29.292s] (current)
     Parsing datafusion-physical-plan v54.0.0 (current)
      Parsed [   0.114s] (current)
    Building datafusion-physical-plan v54.0.0 (baseline)
       Built [  29.039s] (baseline)
     Parsing datafusion-physical-plan v54.0.0 (baseline)
      Parsed [   0.113s] (baseline)
    Checking datafusion-physical-plan v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.755s] 223 checks: 222 pass, 1 fail, 0 warn, 30 skip

--- failure trait_method_marked_deprecated: trait method #[deprecated] added ---

Description:
A trait method is now #[deprecated]. Downstream crates will get a compiler warning when using this method.
        ref: https://doc.rust-lang.org/reference/attributes/diagnostics.html#the-deprecated-attribute
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.48.0/src/lints/trait_method_marked_deprecated.ron

Failed in:
  method required_input_distribution in trait datafusion_physical_plan::execution_plan::ExecutionPlan in /home/runner/work/datafusion/datafusion/datafusion/physical-plan/src/execution_plan.rs:98
  method required_input_distribution in trait datafusion_physical_plan::ExecutionPlan in /home/runner/work/datafusion/datafusion/datafusion/physical-plan/src/execution_plan.rs:98

     Summary semver requires new minor version: 0 major and 1 minor checks failed
    Finished [  60.860s] datafusion-physical-plan
    Building datafusion-sqllogictest v54.0.0 (current)
       Built [ 151.860s] (current)
     Parsing datafusion-sqllogictest v54.0.0 (current)
      Parsed [   0.018s] (current)
    Building datafusion-sqllogictest v54.0.0 (baseline)
       Built [ 150.309s] (baseline)
     Parsing datafusion-sqllogictest v54.0.0 (baseline)
      Parsed [   0.020s] (baseline)
    Checking datafusion-sqllogictest v54.0.0 -> v54.0.0 (no change; assume patch)
     Checked [   0.093s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 304.985s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jun 25, 2026
Comment thread datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs Outdated
Comment thread datafusion/physical-expr/src/partitioning.rs Outdated
@gene-bordegaray

Copy link
Copy Markdown
Contributor Author

cc: @gabotechs @stuhood

@gene-bordegaray gene-bordegaray changed the title [WIP] Support co-partitioned range hash joins Support co-partitioned range hash joins Jun 25, 2026
@gene-bordegaray gene-bordegaray force-pushed the gene.bordegaray/2026/06/range-partitioned-joins branch from 8583303 to 45d598b Compare June 25, 2026 12:59
@gene-bordegaray gene-bordegaray marked this pull request as ready for review June 25, 2026 13:04
Comment thread datafusion/sqllogictest/test_files/range_partitioning.slt
@stuhood

stuhood commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Amazing timing. Will get you some feedback on this by early next week! Thank you!

@gene-bordegaray

gene-bordegaray commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Amazing timing. Will get you some feedback on this by early next week!

@stuhood great, thank you! I also have a huge line of follow up issues to support more join types that we can split up 👍

I am trying to make smaller tickets as more of the plumbing gets in to get more people involved

@gene-bordegaray gene-bordegaray changed the title Support co-partitioned range hash joins Support co-partitioned range inner equi joins Jun 25, 2026
@gene-bordegaray gene-bordegaray force-pushed the gene.bordegaray/2026/06/range-partitioned-joins branch from 45d598b to 2fca2bb Compare June 26, 2026 15:21
@gene-bordegaray

gene-bordegaray commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

I have added a stacked PR that shows how I imagine Aggregations will consume the KeyPartitioned API: gene-bordegaray#6 (not done yet 😄)

@gabotechs gabotechs 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.

This is looking good! left a first round of comments.

The main one is about trying to collapse Distribution::KeyPartitioned and Distribution::HashPartitioned into just a single Distribution variant, I do see that in pretty much all places the code that handles both is the same, and I get the feeling that Distribution::HashPartitioned is just an unfortunate name that was choosen a while back, but it's not really coupled to hashes.

Comment thread datafusion/physical-expr/src/partitioning.rs Outdated
Comment thread datafusion/physical-expr/src/partitioning.rs Outdated
Comment thread datafusion/physical-expr/src/partitioning.rs Outdated
Comment thread datafusion/physical-expr/src/partitioning.rs Outdated
Comment thread datafusion/physical-expr/src/partitioning.rs Outdated
Comment thread datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs Outdated
@2010YOUY01

Copy link
Copy Markdown
Contributor

I think KeyPartitioned is a really clever idea. I have some thoughts on simplifying the implementation. This is basically the same point as @gabotechs, but expressed slightly differently.

Key idea

KeyPartitioned(expr) is the logical generalization of HashPartitioned(expr): it means that rows with equal values for expr are guaranteed to be in the same partition.

Hash partitioning and range partitioning are two concrete ways to satisfy this requirement.

More precisely:

A plan is KeyPartitioned(k) iff:

for any two rows r1 and r2 in the same input:
    if r1.k = r2.k
    then partition(r1) = partition(r2)

Equivalently, different output partitions contain disjoint sets of key values.

This seems to cover the logical requirement that HashPartitioned(expr) is trying to express today, and it also covers the non-overlapping range partitioning cases. Unifying them should make the implementation simpler.

Example 1: aggregation has one input

select k, avg(v)
from t1
group by k

AggregateExec requires its input to be partitioned by the group keys. Conceptually, that requirement is KeyPartitioned(k).

That means all rows with the same k value must be in the same input partition, so each partition can compute its local groups independently.

For example, both of the following satisfy this requirement:

  • hash partitioned by k
  • range partitioned by k, assuming the ranges are non-overlapping

In either case, we do not need to insert an extra hash repartition before the aggregation.

Example 2: hash join has two inputs

select *
from t1
join t2
on t1.v1 = t2.v1

For a partitioned hash join, it is not enough for each side to be independently KeyPartitioned.

The two sides must also be co-partitioned with respect to the join keys. That means equal join-key values from the left and right inputs must be assigned to the same partition id.

The physical execution pattern is:

Union of:

join(t1_partition_0, t2_partition_0)
join(t1_partition_1, t2_partition_1)
join(t1_partition_2, t2_partition_2)
...

So the required co-partitioning rule is:

left and right are co-partitioned on left.a = right.b iff:

for any left row l and right row r:
    if l.a = r.b
    then partition(l) = partition(r)

This implies some extra compatibility requirements across the two inputs:

  • If both sides are hash partitioned, they must use compatible hash semantics: the same hash function, seed/salt, null handling, and modulo / partition count.
  • If both sides are range partitioned, they must use compatible range boundaries. The simplest case is the same partition count and the same split points. In the future, we may be able to relax this to support more general compatible range layouts.

So I think there are two related concepts:

KeyPartitioned(k):
    a per-input property:
    equal keys within this input go to the same partition

CoPartitioned(left.k, right.k):
    a cross-input property:
    equal join keys across both inputs go to the same partition id

Implementation plan

Here is my initial thoughts on a implementation plan (note I haven't read the related code carefully, so it's just some rough ideas)

  1. Collapse Distribution::HashPartitioned into Distribution::KeyPartitioned conceptually, or rename HashPartitioned to KeyPartitioned, and update the docs to describe the more general semantics.
  2. Update and refine the existing test coverage for hash partitioning under the new semantics.
  3. Support aggregation first. Since aggregation only has one input, it only needs the unary KeyPartitioned property. If the input is already range partitioned by the group keys, we should not insert an extra hash repartition.
  4. Support hash join next. Since join has two inputs, we also need to validate the cross-input co-partitioning requirement described above.

@gene-bordegaray

gene-bordegaray commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

@2010YOUY01 thank you for the comments and suggestions. I do think that KeyPartitioned and HashPartitioned can probably collapse into a single distribution type (I prefer KeyPartitioned as hash is / has been histroically misleading).

I also believe my current implementation of co-partitioning is satisfying the same contract as described here. Since Distribution was not designed for multiple children I like the current co_partitioned_with() API as it can accept any Distributions and check them without a need to redesign the API.

I would be ok with supporting aggregations before multi-input operators. Something I would like to point out though, is if we are going to collapse KeyPartitioned and HashPartitioned, and have RangePartitioning satisfy the KeyPartitioned type in the public satisfaction() method, this will apply to the checks in EnforceDistribution which applies repartitioning across many operators.

If we take this approach we could add explicit checks of what operator we are checking distribuion for in EnforceDistribution and just start with aggregations or add private helpers for each before having RangePartitioning saisfy KeyPartitioned

After thinking about it for a bit I would prefer adding private helpers for each operator and then have general satisfaction for RangePartitioning once a good amount of operators are supported. Let me know what you think 😄

PS: also see my linked aggregation pR in my fork 👍 it will give a good idea of what this looks like using this brnach as a base: gene-bordegaray#6

cc: @gabotechs

@2010YOUY01

Copy link
Copy Markdown
Contributor

After thinking about it for a bit I would prefer adding private helpers for each operator and then have general satisfaction for RangePartitioning once a good amount of operators are supported. Let me know what you think 😄

Thank you for the extra context! This approach makes sense if it's easier to implement.

I think we should start with aggregation first, since single-input cases are simpler.

Two-input/co-partitioned operators are trickier. For example, when a hash join requires its inputs to be [HashPartitioned(left_key), HashPartitioned(right_key)], it's really enforcing the co-partitioning property we're discussing here. We'll need to figure out how to incrementally migrate that to the new API to avoid duplicated implementations in the long term.

@2010YOUY01

Copy link
Copy Markdown
Contributor

Since Distribution was not designed for multiple children I like the current co_partitioned_with() API as it can accept any Distributions and check them without a need to redesign the API.

Making this co-partition requirement part of the ExecutionPlan API seems like a better long-term solution. The existing co_partitioned_with adds a hidden assumption to ExecutionPlan::required_input_distribution: if both sides are KeyPartitioned, then left.co_partitioned_with(right) will be checked somewhere else. This creates a leaky abstraction somehow.

If we can instead make this an explicit requirement at the ExecutionPlan level, the implementation is likely to become simpler and easier to understand.

// Before
    fn required_input_distribution(&self) -> Vec<Distribution> {
        vec![Distribution::UnspecifiedDistribution; self.children().len()]
    }
// After
pub struct RequiredInputDistributions {
    pub per_child: Vec<Distribution>,
    pub cross_child: Vec<CrossChildDistribution>,
}

trait ExecutionPlan {
    fn required_input_distributions(&self) -> RequiredInputDistributions {
        ...
    }
}

The tradeoff is that this would require a large refactor upfront. The should not be a hard requirement for now, and I'm also unsure how to carry this out incrementally, but I would still love to see us move towards this direction sooner.

@gene-bordegaray

Copy link
Copy Markdown
Contributor Author

If we can instead make this an explicit requirement at the ExecutionPlan level, the implementation is likely to become simpler and easier to understand.

// Before
    fn required_input_distribution(&self) -> Vec<Distribution> {
        vec![Distribution::UnspecifiedDistribution; self.children().len()]
    }
// After
pub struct RequiredInputDistributions {
    pub per_child: Vec<Distribution>,
    pub cross_child: Vec<CrossChildDistribution>,
}

trait ExecutionPlan {
    fn required_input_distributions(&self) -> RequiredInputDistributions {
        ...
    }
}

The tradeoff is that this would require a large refactor upfront. The should not be a hard requirement for now, and I'm also unsure how to carry this out incrementally, but I would still love to see us move towards this direction sooner.

@2010YOUY01

This is an intersting idea, thank you. Let me cherry pick my aggregation commit to be stacked on main and can open something up for a unary operator first. I think something like this would be worth exploring 👍

@gene-bordegaray

Copy link
Copy Markdown
Contributor Author

Also, I would prefer to support aggregations and joins (and maybe a few more operators) before deciding to rename or replace HashPartitioned with KeyPartitioned. I do not want to over commit to a large public API change before working through kinks with a few differently shaped use cases.

For something like the aggregations, I can just introduce a private helper in EnforceDistribution to have RangePartitioning satisfy HashPartitioned.

@gabotechs gabotechs 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.

Left a last batch of non blocking comments.

This looks really good @gene-bordegaray, nice work!

Before merging, it would be nice to get the blessing from @2010YOUY01, as this seems quite an important change that could use another committer's +1.

} else {
let streaming_benefit = if child.data {
preserving_order_enables_streaming(&plan, &child.plan)?
enforce_distribution_relationships(

@gabotechs gabotechs Jul 8, 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.

TBH, it's not super pretty to have this function call here in the middle, but I'd not now how to make it better without a major refactor of this file...

My impressions is that this file is worth refactoring. In the current state, it's super difficult to understand what's happening.

This PR is not making it worst though, so one thing we could do is:

  1. Make up for it with some good comments at the top of this function call and probably below it.
  2. File an issue for refactoring this file, I might take this one myself.

Comment thread datafusion/physical-plan/src/distribution_requirements.rs Outdated
Comment thread datafusion/physical-plan/src/distribution_requirements.rs
Comment thread datafusion/physical-plan/src/distribution_requirements.rs Outdated
Comment thread datafusion/physical-plan/src/distribution_requirements.rs
Comment thread datafusion/sqllogictest/test_files/range_partitioning.slt Outdated
Comment thread datafusion/sqllogictest/test_files/range_partitioning.slt
@gene-bordegaray

Copy link
Copy Markdown
Contributor Author

I have addressed the last round @gabotechs, thank you for the review. There are some PRs stacked on this and I have some issues lined up that depend on this work being in. We can wait to see if we can get another +1 👍

@gabotechs

Copy link
Copy Markdown
Contributor

It'd be nice to also get a +1 from @2010YOUY01, as he's made some very useful suggestions that have helped shape this PR.

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

LGTM, Thank you!

Alternative Design

I’d like to share a design idea I have in mind. If we were implementing this from scratch, I suspect this alternative might be simpler. I understand this is not feasible in a single PR, and the current approach is practical and quite good already. So this idea is only to provide additional perspectives.

The current design seems to treat per-child distribution requirements and cross-child requirements as separate mechanisms, then handles them individually.

An alternative approach:

  • Treat co-partitioning as the top-level requirement to satisfy.
  • Make enforce_distribution do one thing: given the current source physical layout, config values, and statistics, decide the cheapest operator(s) to insert.

I think this maps more directly to the essence of the problem, so the implementation might become simpler. Separating these concepts may require keeping implicit consistency across different places, and the mechanisms can sometimes cancel each other out.

enum DistributionRequirement {
    // Single-child case
    Distribution(Distribution),

    // Multi-child co-partitioning case
    CoPartition(CoPartition),
}

For example:

-- t1 and t2 are both source tables backed by Parquet
SELECT *
FROM t1
INNER JOIN t2
ON t1.v1 = t2.v1;

Suppose the physical layout is:

# t1: 4 similarly sized range partitions
t1:
  p1.parquet # v1 range [0, 100)
  p2.parquet # v1 range [100, 200)
  p3.parquet # v1 range [200, 300)
  p4.parquet # v1 range [300, 400)

# t2: one partition, uniform distribution over [0, 400)
t2:
  p1.parquet # v1 range [0, 400)

And the configuration is:

target_partitions = 2

In this model, enforce_distribution would look at the physical layout, configuration, and statistics, and could decide to:

  • Optimize t1’s scan operator into this shape:
    • partition 1: [p1.parquet, p2.parquet]
    • partition 2: [p3.parquet, p4.parquet]
  • add RepartitionExec(range, split_points = [200]) above the t2 scan operator

If we first satisfy the per-child requirements independently, it seems harder to reach this optimal plan without adding many special cases, and we may end up with an extra hash repartition on v1.

Related Issue

One related issue is that we currently enforce sorting and distribution through separate mechanisms. It looks like they can cancel each other out, which makes the implementation harder to maintain.

The essence of the issue is the same: given the input plan and required plan properties, find the cheapest plan change that satisfies the requirement. Unifying these mechanisms would likely simplify the overall implementation too, but that looks like an even larger refactor.

JOIN range_partitioned r ON l.range_key = r.range_key;
----
physical_plan
01)HashJoinExec: mode=Partitioned, join_type=Inner, on=[(range_key@0, range_key@0)], projection=[range_key@0, value@1, value@3]

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 and optional: since we're only checking if extra hash repartitioning is added for most EXPLAIN tests, we could do

HashJoinExec: <slt:ignore>
--DataSourceExec: <slt:ignore>
--DataSourceExec: <slt:ignore>

This way less future maintenance is needed, if unrelated explain plan changes break this test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added ignore to the long file list but kept the partitioning metadata for sanity checks 👍

@gene-bordegaray gene-bordegaray force-pushed the gene.bordegaray/2026/06/range-partitioned-joins branch from 29ab3a2 to f2e6d2c Compare July 10, 2026 11:50
@gene-bordegaray

Copy link
Copy Markdown
Contributor Author

LGTM, Thank you!

Alternative Design

I’d like to share a design idea I have in mind. If we were implementing this from scratch, I suspect this alternative might be simpler. I understand this is not feasible in a single PR, and the current approach is practical and quite good already. So this idea is only to provide additional perspectives.

The current design seems to treat per-child distribution requirements and cross-child requirements as separate mechanisms, then handles them individually.

An alternative approach:

  • Treat co-partitioning as the top-level requirement to satisfy.
  • Make enforce_distribution do one thing: given the current source physical layout, config values, and statistics, decide the cheapest operator(s) to insert.

I think this maps more directly to the essence of the problem, so the implementation might become simpler. Separating these concepts may require keeping implicit consistency across different places, and the mechanisms can sometimes cancel each other out.

enum DistributionRequirement {
    // Single-child case
    Distribution(Distribution),

    // Multi-child co-partitioning case
    CoPartition(CoPartition),
}

For example:

-- t1 and t2 are both source tables backed by Parquet
SELECT *
FROM t1
INNER JOIN t2
ON t1.v1 = t2.v1;

Suppose the physical layout is:

# t1: 4 similarly sized range partitions
t1:
  p1.parquet # v1 range [0, 100)
  p2.parquet # v1 range [100, 200)
  p3.parquet # v1 range [200, 300)
  p4.parquet # v1 range [300, 400)

# t2: one partition, uniform distribution over [0, 400)
t2:
  p1.parquet # v1 range [0, 400)

And the configuration is:

target_partitions = 2

In this model, enforce_distribution would look at the physical layout, configuration, and statistics, and could decide to:

  • Optimize t1’s scan operator into this shape:

    • partition 1: [p1.parquet, p2.parquet]
    • partition 2: [p3.parquet, p4.parquet]
  • add RepartitionExec(range, split_points = [200]) above the t2 scan operator

If we first satisfy the per-child requirements independently, it seems harder to reach this optimal plan without adding many special cases, and we may end up with an extra hash repartition on v1.

Related Issue

One related issue is that we currently enforce sorting and distribution through separate mechanisms. It looks like they can cancel each other out, which makes the implementation harder to maintain.

The essence of the issue is the same: given the input plan and required plan properties, find the cheapest plan change that satisfies the requirement. Unifying these mechanisms would likely simplify the overall implementation too, but that looks like an even larger refactor.

@2010YOUY01 I see what you are saying here, and I think this is a great long-term model to have in mind. It seems like what you are saying is the current contract is split into two:

  1. Each child satisfies their individual distributions
  2. Then they are aligned to be co-partitioned

This is a good design with what we are given, it would be better to just explicitly say upfront that these children need to be co-partitioned and derive that immediately.

With the current design I could see us moving toward this shape cleanly. We could introduce something like you suggested

  enum InputDistributionRequirement {
      PerChild {...},
      CoPartitioned {...},
  }

and then rather than operators asking for a Distribution they can directly ask for per child or co-partitioned requirements. I think this would, again like you suggested, be pretty big PR (or PRs) but I am up to keep looking into this 👍

@comphead comphead added this pull request to the merge queue Jul 10, 2026
@comphead

Copy link
Copy Markdown
Contributor

It might be a good topic for the DF 55 blog @gene-bordegaray WDYT?

@gene-bordegaray

Copy link
Copy Markdown
Contributor Author

It might be a good topic for the DF 55 blog @gene-bordegaray WDYT?

@comphead yes would be more than happy to talk about! Still much to do 🚀

Merged via the queue into apache:main with commit 63e25c1 Jul 10, 2026
39 checks passed
stuhood pushed a commit to paradedb/datafusion that referenced this pull request Jul 14, 2026
…rtitioned requirements (apache#23416)

## Which issue does this PR close?

- Closes apache#23289.

Rebased on main now that apache#23184 has merged. Overlaps with apache#23355, which
also opts the window execs into range satisfaction as part of its
PartitionedTopK work — happy to rebase whichever lands second.

## What changes are included in this PR?

In one commit:

* Opt `WindowAggExec` and `BoundedWindowAggExec` with partition keys
into range satisfaction via
`InputDistributionRequirements::allow_range_satisfaction_for_key_partitioning`,
mirroring `AggregateExec`. Compatible range-partitioned inputs then
satisfy the window key requirement without a hash repartition; subset
satisfaction and the hash fallback for incompatible keys come from the
existing satisfaction machinery. Windows without `PARTITION BY` keep
requiring a single partition.

## Are these changes tested?

Yes: new `slt` tests in `range_partitioning.slt` (exact and subset
reuse, rehash on incompatible keys, `subset_repartition_threshold` /
`preserve_file_partitions` / `target_partitions` behavior,
`WindowAggExec` via an unbounded frame, and no-`PARTITION BY`), plus
plan-shape tests in `enforce_distribution.rs` and acceptance/rejection
tests in `sanity_checker.rs`.

## Are there any user-facing changes?

No: only plan changes.
mkleen pushed a commit to mkleen/datafusion that referenced this pull request Jul 15, 2026
## Which issue does this PR close?

- Closes apache#23450.

## Rationale for this change

apache#23184 allowed compatible range-partitioned inputs to satisfy
partitioned inner joins without hash repartitioning. However, join
output still downgraded `Partitioning::Range` to `UnknownPartitioning`,
so downstream joins and aggregates could not reuse the preserved range
layout and inserted avoidable hash repartitions.

## What changes are included in this PR?

- Preserve `Partitioning::Range` in `adjust_right_output_partitioning`.
- Shift right-side range-ordering column indexes into the joined schema
while retaining split points and sort options.
- Add unit coverage for compound range keys and non-default sort
options.
- Update SQL logic test plans for nested range joins and a range join
feeding an aggregate.

## Are these changes tested?

Yes.

- `./dev/rust_lint.sh`
- `cargo clippy --all-targets --all-features -- -D warnings`
- `cargo test -p datafusion-physical-plan
test_adjust_right_output_partitioning_preserves_range`
- `cargo test --profile=ci --test sqllogictests --
range_partitioning.slt`
- `ulimit -n 10240 && RUST_BACKTRACE=1 cargo test --profile ci --exclude
datafusion-examples --exclude datafusion-benchmarks --exclude
datafusion-cli --workspace --lib --tests --bins --features
avro,json,backtrace,extended_tests,recursive_protection,parquet_encryption`

## Are there any user-facing changes?

Yes. Physical plans can preserve proven range partitioning through
joins, allowing compatible downstream joins and aggregates to avoid
unnecessary hash repartitioning. There are no public API changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change core Core DataFusion crate datasource Changes to the datasource crate optimizer Optimizer rules physical-expr Changes to the physical-expr crates 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.

Allow co-partitioned range inputs to satisfy inner partitioned hash joins

5 participants