Skip to content

feat: store start/end bounds in Partitioning::Range - #26593

Open
jayshrivastava wants to merge 11 commits into
apache:mainfrom
jayshrivastava:js/range-partition-bounds
Open

jayshrivastava wants to merge 11 commits into
apache:mainfrom
jayshrivastava:js/range-partition-bounds

Conversation

@jayshrivastava

@jayshrivastava jayshrivastava commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Important

Fork CI setup: For pull requests from a fork to main, enable workflows in your fork's Actions tab before you push.
See the fork CI setup guide.

Which issue does this PR close?

Rationale for this change

Most of the context is in the issue above.

The main use case is that I want to partition a query across multiple machines while assigning Partitioning::Range to each machine. Because we don't store a start and end bound, you lose information:

Partitioning::Range(4, split_points: [10, 20, 30]) 

// Split into 2 workers, assuming target_partitions=2
Range(2, split_points: [10]) // 2 partitions: x < 10 and  x >= 10
Range(2, split_points: [30]) // 2 partitions: x < 30, x >= 30

Because split point 20 is lost, both machines have overlapping ranges x >= 10 and x < 30. This means you cannot, for example, coalesce them back into the original partitioning. In that scenario, you you have to basically fall back to Partitioning::Unknown(4)

This change adds optional start and end bounds, so you can split up the range like so:

Partitioning::Range(4, split_points: [10, 20, 30]) 

// Split into 2 workers, assuming target_partitions=2
Partitioning::Range(2, split_points: [10], end_bound: 20) // 2 partitions: x < 10 and  10 <= x < 20 
Partitioning::Range(2, split_points: [30], start_bound: 20) // 2 partitions: 20 <= x < 30 and  30 <= x 

Now, you preserve information.

  • The ranges are partitioned across workers now. Because they are bounded, you can implement certain shuffle optimizations in a Range - Range shuffle.
  • The ranges can be coalesced back into Partitioning::Range(4) instead of Partitioning::Unknown(4)

What changes are included in this PR?

The diff is large but it's a very straightforward, mechanical change.

  1. Partitioning::Range now has 2 new fields.
    /// Inclusive start of the key space, in `ordering` order.
    start_bound: Option<SplitPoint>,
    /// Exclusive end of the key space, in `ordering` order.
    end_bound: Option<SplitPoint>,

None means "unbounded", which is equivalent to the default today. The difference is that now, as a user, I can split this up into N ranges without losing information by storing split points in the bounds.

  1. Minor change: split points no longer need to be strictly increasing. This allows a user to pad empty ranges if they would like.

  2. To support bounds in Partitioning::Range, RepartitionExec has some minor changes to the router.

What is the testing strategy for this PR?

Unit tests and existing tests.

Are there any user-facing changes?

RangePartitioning can now optionally store bounds in addition to internal split points.

pub fn try_with_bounds(
    mut self,
    start_bound: Option<SplitPoint>,
    end_bound: Option<SplitPoint>,
) -> Result<Self>

FFI_RangePartitioning has two new fields. Because this changes the struct's C layout, this is technically an API change.

start_bound: FFI_Option<SVec<WrappedArray>>,
end_bound: FFI_Option<SVec<WrappedArray>>,

@github-actions github-actions Bot added logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates optimizer Optimizer rules core Core DataFusion crate common Related to common crate proto Related to proto crate ffi Changes to the ffi crate physical-plan Changes to the physical-plan crate labels Oct 22, 2026
@jayshrivastava jayshrivastava changed the title wip feat: store start/end bounds in Partitioning::Range Oct 22, 2026
@jayshrivastava

Copy link
Copy Markdown
Contributor Author

👋🏽 @stuhood @saadtajwar @avantgardnerio would any of you be interested in reviewing this? This is related to #22395, which you all have participated in. Please lmk!

@saadtajwar

Copy link
Copy Markdown
Contributor

👋🏽 @stuhood @saadtajwar @avantgardnerio would any of you be interested in reviewing this? This is related to #22395, which you all have participated in. Please lmk!

Hey @jayshrivastava - thanks for tagging, happy to review this! I'll try to get to this in the next couple of days :)

@jayshrivastava

Copy link
Copy Markdown
Contributor Author

Thanks @saadtajwar!

@saadtajwar saadtajwar 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! Just a quick question and small suggestion/nit! Super exciting feature :)

Comment on lines 69 to +89
@@ -89,10 +86,10 @@ pub fn validate_range_split_points(
split_points[0].values(),
split_points[1].values(),
sort_options,
)? != Ordering::Less
)? == Ordering::Greater

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.

Hmm out of curiosity, can I ask if there's context around the addition of equal split points? Is there a case for distributed execution in which it would be helpful to have an empty partition that I may be missing?

Another thing which might be useful is to be able to pad empty partitions. Ex. use duplicate split points such as 10, 10, 10, 10 to indicate empty partitions.

I see the above comment in #26509, but would definitely like to understand use cases for the empty partitions :)

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.

The main reason is that padding empty partitions is supported Partitioning::Unknown. For example, I I can add an empty file group. Doing the same when a data source is Partitioning::Range introduces an error.

In distributed-datafusion, this feature ensure all tasks (horizontally distributed parts of the plan) have the same number of partitions. This simplifies the code a lot. I imagine the same is true for ballista etc but I'm not 100% sure. We could move away from this if anyone feels strongly, but I figured it's a pretty inoffensive change to make in this repo.

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, makes sense to me! Thanks!

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.

In distributed-datafusion, this feature ensure all tasks (horizontally distributed parts of the plan) have the same number of partitions.

We could move away from this if anyone feels strongly, but I figured it's a pretty inoffensive change to make in this repo.

I'd rather DataFusion not take on that invariant without more discussion. As far as I can find, it doesn't have one today. Partition counts already differ from operator to operator, and the only places that need equal counts are children that must be co-partitioned (compatible_co_partitioning_layout) and interleaved inputs (can_interleave). I'd like to keep DataFusion open to running operators with different partition counts, for example an operator that doesn't need as many partitions coalescing adjacent ranges (the SourceFiner case in #25483). Padding with equal split points pushes the other way: it raises partition counts to match instead of letting them differ.

Bounds already let a task have fewer partitions without padding. In Example 4 of datafusion-contrib/datafusion-distributed#782, the last task could be Range(2, [60], start=50) instead of split points [50, 60] with start=50 and an empty first partition. Could we keep validate_range_split_points strict, require each bound to lie strictly outside the retained samples, and leave padding to DFD? That also removes the empty range where start_bound == end_bound. Once DataFusion accepts equal split points, tightening the check later would reject layouts that downstream code already builds. If the check stays strict, the upgrade-guide entry I asked for on line 69 only needs to cover the bounds fields.

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.

Making empty partitions irrepresentable in the Partitioning::Range specification can be cornering.

For example, having an empty FileGroup in a parquet scan is something that can happen, there's no invariant that prevent that, however, that'd automatically make it incompatible with the current specification of Partitioning::Range. At some point, FileScanConfig might want to expose range partitioning here https://github.com/apache/datafusion/blob/main/datafusion/datasource/src/file_scan_config/mod.rs#L908-L940, so how should it react in the event of one of the FileGroups being empty?

One other idea that comes to mind is to explicitly model empty partitions somehow, maybe making the split points accept a ScalarValue::Null and accounting for it?

Regardless of the mechanism, I do see value in making empty partitions representable.

@jayshrivastava jayshrivastava Nov 4, 2026 •

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.

Partition counts already differ from operator to operator, and the only places that need equal counts are children that must be co-partitioned (compatible_co_partitioning_layout) and interleaved inputs (can_interleave). I'd like to keep DataFusion open to running operators with different partition counts, for example an operator that doesn't need as many partitions coalescing adjacent ranges (the SourceFiner case in #25483). Padding with equal split points pushes the other way: it raises partition counts to match instead of letting them differ.

I would expect can_interleave and compatible_co_partitioning_layout to be improved so that we are able to interleave arbitrary partitioned data. Ex. Range(3, [60, 80], start=50) should be interleaved with Range(4, [60, 80]). You mentioned this below as well. I'll file an issue.

Also, my experience has been that, if the user wants to avoid a RepartitionExec or wants an InterleaveExec, they have to make sure the correct partitioning properties are declared on their data source. I've even gone as far to declare data sources as Hash() partitioned when they are not. This is to say that I think a user would probably go out of their way to declare interleavable and/or compatible, not necessarily relying on datafusion.

Padding with equal split points pushes the other way: it raises partition counts to match instead of letting them differ

I'd say the uses for padding are user-defined, like our invariant in DFD and the KLL example Brent mentioned above.

Padding would not really be used to achieve co-partitioning. For example, I don't see a way to pad Range(2, [50]) to make it compatible with Range(3, [50, 60]). Padding with Range(3, [50, 50]) doesn't work. Converting to Range(3, [50, 60]) requires a repartition, splitting up the last partition.

In Example 4 of datafusion-contrib/datafusion-distributed#782, the last task could be Range(2, [60], start=50) instead of split points [50, 60] with start=50 and an empty first partition.

The main distinction is that the former has 2 partitions and latter has 3. You force the user to have an additional tasks spawned if they want to add bounds to Range partitioning.

Padding empty partitions also introduces additional tasks, but the user can opt out of this by simply not padding.

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'd also double check how easy it'd be to remove the invariant in DFD about needing the same partition count in all task-specialized plans in a stage.

If we were able to remove that invariant, will it avoid introducing some sense of padding in this project?

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 wanted to say thank you for bringing some more eyes and perspectives to this topic!

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.

And thank you for being flexible in your understanding! Always a pleasure...

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.

+1. Appreciate the details reviews from everyone :)

@jayshrivastava

jayshrivastava commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

FAQ

  1. Why make bounds optional instead of using type::MIN and type::MAX

Some types don't have a max or a reasonable value ex. what's the max of a string? "zzzzzz"? For variable length types too

  1. Why is this PR so large?

Tests around range satisfaction. Generally, a join may join two different ranges, with or without bounds. 50% of this PR is just adding test coverage around that. For example, the join type influences the behavior here. Happy to delete tests if people thing any are less useful.

@mbutrovich
mbutrovich self-requested a review November 2, 2026 20:39

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

Thanks @jayshrivastava! The join-type test makes it easy to see which side's bounds each join can inherit.

Should we settle the alternative @stuhood raised in datafusion-contrib/datafusion-distributed#628 (comment) before bounds become part of the public range contract? There, every task keeps the full Range(N, split_points) and the partitions outside its slice are empty, which needs no new fields on RangePartitioning. Spark describes the same kind of slice by index: a coalesced AQE read gives each reader a span of reducer partitions and keeps the original RangePartitioning with a smaller count (AQEShuffleReadExec). As I read your reply in datafusion-contrib/datafusion-distributed#628 (comment) and the padding thread here, this PR still pads tasks with empty partitions, so what the bounds save is the N - N/W idle partitions on each task. In exchange, every range rule has to account for bounds. For example, equality now includes them, so can_interleave rejects inputs that differ only in bounds, and the layout relationships in #25483 will need to define how bounds compose. Is the idle-partition overhead the remaining cost of that approach, or is there a plan shape it can't express?

@jayshrivastava

Copy link
Copy Markdown
Contributor Author

Should we settle the alternative @stuhood raised in datafusion-contrib/datafusion-distributed#628 (comment) before bounds become part of the public range contract? There, every task keeps the full Range(N, split_points) and the partitions outside its slice are empty, which needs no new fields on RangePartitioning.

Stu's approach is valid. I also have a similar one in datafusion-contrib/datafusion-distributed#782. I decided to publish this PR because I felt that those two approaches diverge from our goal of keep DFD as close to DF as possible and as thin of a layer over the single node implementation. Introducing these bounds reduces the complexity of datafusion distributed very significantly, and the lines of code as well.

Ballista opted to implement range shuffles without introducing bounds to DF and you can see the tradeoffs that needed to be made.

Consider that we want to distributed this across two tasks

Global: Range(4, splits=[10, 20, 30])

          p0           p1           p2           p3
     [-inf, 10)    [10, 20)     [20, 30)     [30, +inf)
     \___________________/     \____________________/
             Task 0                    Task 1

In ballista, the RangeShuffleReaderExec reports UnknownPartitioning (see reader properties, task slicing)


                         Range shuffle
                         /           \
                        v             v
                Task 0                 Task 1
           RangeShuffleReader     RangeShuffleReader

  Actual ranges:
           [-inf,10) [10,20)      [20,30) [30,+inf)

  DataFusion property:
           UnknownPartitioning(2) UnknownPartitioning(2)

The UnknownPartitioning makes that within a task, we don't have the range partitioning property anymore. Now, a custom plan node won't know its partitioned and may not be able to make certain optimizations.

Your suggestion is to have each task advertise all partitions but mark some as empty like so

                    p0       p1       p2       p3
  Task 0:           data     data     empty    empty
  Task 1:           empty    empty    data     data

  Both advertise Range(4, splits=[10,20,30]).

That is a reasonable alternative, but those empty partitions have an extra cost, the RepartitionExec spawns a reader task and a completion task for every input partition.

Spark describes the same kind of slice by index: a coalesced AQE read gives each reader a span of reducer partitions and keeps the original RangePartitioning with a smaller count (AQEShuffleReadExec).

On Spark, I think the distinction is that its physical RangePartitioning describes ordering and a partition count; it does not contain concrete split values. You can prove that two tasks have disjoint partitions / ranges because the indexes are disjoint. Ie. one task gets 0,1 and another gets 2,3. In datafusion, you can't prove that partitions are disjoint without bounds.

As I read your reply in datafusion-contrib/datafusion-distributed#628 (comment) and the padding thread here, this PR still pads tasks with empty partitions, so what the bounds save is the N - N/W idle partitions on each task. In exchange, every range rule has to account for bounds. For example, equality now includes them, so can_interleave rejects inputs that differ only in bounds, and the layout relationships in #25483 will need to define how bounds compose. Is the idle-partition overhead the remaining cost of that approach, or is there a plan shape it can't express?

Regarding idle partitions, the user has full control here. They can choose to not have them. This PR only introduces the option to pad.

On can_interleave, I would say the current implementation is correct. We would not interleave these:

Input A:  [0,10) [10,20) [20,30) [30,35)
Input B:  [0,10) [10,20) [20,30) [30,40)

However, we may want to interleave them and use this layout.

Output:   [0,10) [10,20) [20,30) [30,40)

Because this PR is a bit large already, I can file a follow up issue for this. Because bounds are new, I doubt many users will run into this. When someone does, that optimization can be implemented fairly easily.

@kumarUjjawal kumarUjjawal 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 @jayshrivastava

This looks good, just couple of items.

@mbutrovich

Copy link
Copy Markdown
Contributor

That is a reasonable alternative, but those empty partitions have an extra cost, the RepartitionExec spawns a reader task and a completion task for every input partition.

Thanks, that tells me the difference is run-time cost and DFD code, not a plan the alternative can't express. Since bounds are permanent once they ship, could we measure that cost before settling on them? Each task pays for N - N/W empty partitions: two tasks spawned per input partition in RepartitionExec, plus an empty stream through each operator above the scan. That grows as N per task and N * W across a stage, so a sweep up to the largest shapes DFD expects would show what bounds save. For example, 16 partitions per task on 100 tasks is Range(1600) with 1584 empty partitions on each task. @stuhood also asked in datafusion-contrib/datafusion-distributed#628 (comment) whether a way to declare empty partitions for every Partitioning would remove that cost for Hash too. I don't see that answered yet, and it would avoid a Range-only field.

You can prove that two tasks have disjoint partitions / ranges because the indexes are disjoint. Ie. one task gets 0,1 and another gets 2,3. In datafusion, you can't prove that partitions are disjoint without bounds.

As I read it, the same index argument works in DataFusion under the empty-partition alternative. Every task advertises the same Range(4, [10, 20, 30]), so partition k covers the same keys on every task, and a task that owns 0, 1 has rows only in those partitions. The overlap in the PR description comes from giving each task its own Range(2, [10]) or Range(2, [30]), which drops the global indexes.

I think the more useful comparison with Spark is where it keeps the slice. Its RangePartitioning carries only an ordering and a count. The sampled bounds stay in the exchange's RangePartitioner, and Spark never treats two range layouts as co-partitioned because the bounds are sampled (RangeShuffleSpec). A coalesced read gives each reader a contiguous span of reducer indexes and keeps RangePartitioning with a smaller count (AQEShuffleReadExec). As I read it, that works because a Spark partitioning describes the whole stage, so no task needs to know its own slice.

Spark's coalescing also covers a case DFD will hit: partitions too small to be worth a worker each. AQE packs contiguous reducer partitions up to a target size (ShufflePartitionsUtil). The read keeps RangePartitioning only while every spec is a contiguous, non-overlapping span (isCoalescedRead). Once it splits a skewed partition, it falls back to UnknownPartitioning (L108-L109). In DataFusion terms, coalescing a span drops the split points inside it, so the result is still a plain Range over a subset of the split points. Should DFD coalesce that way before running a range layout on fewer workers? It doesn't remove the per-task slice question, but it shrinks both sides of the trade-off. Under the empty-partition alternative, each task's empty partitions drop from N - N/W to M - M/W for the coalesced count M.

CockroachDB is the other range-partitioned engine I checked. It splits scans into per-node key spans (PartitionSpans), but its optimizer has no range-layout property (physical.Required). Its key-span range router is the closest match to this PR, including the error for rows outside every span. Bulk jobs such as restore use it, not SQL query plans.

So neither system puts a worker's key bounds in a partitioning property that the optimizer compares. DataFusion already goes further than Spark, because RangePartitioning carries split points and drives co-partitioning. Each DFD task also runs its own DataFusion plan, so its partitioning can describe only its own slice. Bounds fill that gap with key values. Empty partitions fill it by giving each task the stage-wide layout that Spark has. Bounds are Range-only, and every rule that compares range layouts (equality, can_interleave, #25483) has to define what they mean. Empty partitions need no new API but cost the partitions above. Could the PR description lay out that trade-off, with the measurement, so we can decide on it?

Because this PR is a bit large already, I can file a follow up issue for this.

A follow-up works for interleaving. As I read it, that's optimizer behavior behind the existing can_interleave signature. Could you file the issue and link it here?

The other half of my question decides what the public API means, so I don't think it can wait for a follow-up: how bounds take part when layouts are compared. Today has_same_layout ignores bounds and == includes them. #25483 will build its relationships on one of those, and its SourceFiner case needs bounds across tasks. For example, the last partition of Range(2, [10], end=20) maps into the first partition of Range(2, [20]) only because of end=20. If #25483 needs bounds to mean something else, we'd be changing the documented semantics of a released type. The API health policy asks us to avoid breaking changes and to deprecate instead, but a change to what == means can't be deprecated, so downstream code would change behavior without warning. Could we settle in this PR how bounds take part in layout comparison and document it on has_same_layout and PartialEq?

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

Thanks @jayshrivastava for the quick turnaround and for the detailed reply on the design. The adapt fix and the new doctest look good. Bounds become a permanent part of the public RangePartitioning once released, so I'd like us to finish comparing the alternatives before we adopt them.

Comment thread datafusion/common/src/partitioning.rs Resolved
.chain(end_bound.iter())
.cloned()
.collect::<Vec<_>>();
validate_range_split_points(

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.

require each bound to lie strictly outside the retained samples

Ballista needs this one non-strict too. A task's bounds are the cuts on either side of its slice, so with repeated cuts start_bound can equal its first split point, and a task whose only partition sits between two equal cuts has start_bound == end_bound.

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.

Kept this non strict for now.

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

As currently written, I think this PR is good from a Ballista range partitioning perspective - as long as the looser invariants remain for split points and bounds, we can adopt this for at least part of our plan.

@avantgardnerio
avantgardnerio dismissed their stale review November 4, 2026 17:55

As currently written, I think this PR is good from a Ballista range partitioning perspective - as long as the looser invariants remain for split points and bounds, we can adopt this for at least part of our plan.

@avantgardnerio

Copy link
Copy Markdown
Contributor

the difference is run-time cost and DFD code, not a plan the alternative can't express

I think there's an information difference too. Under the empty-partition alternative, a task advertises Range(16, ...), and which partitions are empty lives only in the source's private state. Nothing in the plan says the task's keys fall within [cut3, cut7), so no rule running in that task can use it. With bounds, the task's partitioning states its own key range.

Bounds aren't used for optimization yet, but they're what pruning the other side of a join, tightening dynamic filters, or key statistics would need. Getting the same information under the alternative would need a way to declare empty partitions on Partitioning, along the lines stuhood suggested, which would be a separate API.

More generally, a property the plan doesn't expose is one no rule can act on, so I'd rather the partitioning describe the task's actual key range than leave it implicit in the source.

Ballista is in the same position as DFD here: each task runs its own DataFusion plan over a slice of the global partitions.

@jayshrivastava

Copy link
Copy Markdown
Contributor Author

For #25483, I don't anticipate much changing. I think we'll need to add code related to layouts and in that implementation, we can handle bounds, similarly to to can_interleave and compatible_co_partitioning_layout. (I filed #27106 for that by the way to make sure we update those).

For layouts, here's an example where we might want to account for bounds. Suppose a task has Range(2, [10], end=20), and the destination has Range(2, [20]), we would want this behavior:


  Source                            Target

  S0: (-inf, 10) ----+
                    +-------------> T0: (-inf, 20)
  S1: [10, 20) ------+

                                    T1: [20, +inf)
                                        receives nothing

Without bounds, this could happen in theory.

  Source                            Target

  S0: (-inf, 10) ------------------> T0: (-inf, 20)
                                    ^
  S1: [10, +inf) -------------------+
                    |
                    +-------------> T1: [20, +inf)

Since no one has really started on #25483, this is pretty speculative. I think we could find a way to ensure the desired behavior though.

@jayshrivastava

jayshrivastava commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

I'm still thinking about has_same_layout and adapt. Will need a bit more time.

Comment on lines 239 to +243
split_points: Arc<[SplitPoint]>,
/// Inclusive start of the key space, in `ordering` order.
start_bound: Option<SplitPoint>,
/// Exclusive end of the key space, in `ordering` order.
end_bound: Option<SplitPoint>,

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.

Not sure if this has been considered yet, maybe I'm late to the party, but has anyone thought about just making split_points require 2 more positions, the start and end bound?

Today we have something like this:

Range([10, 20, 30]) -> p0: [-inf, 10) p1: [10, 20) p2: [20, 30) p3: [30, +inf)

partition_count == split_points.len() + 1

And this PR proposes adding optional start and end bounds, so that we have something like this:

Range([10, 20, 30], start=Some(5), end=Some(35)) -> p0: [5, 10) p1: [10, 20) p2: [20, 30) p3: [30, 35)
Range([10, 20, 30], start=None, end=Some(35)) -> p0: [-inf, 10) p1: [10, 20) p2: [20, 30) p3: [30, 35)
Range([10, 20, 30], start=Some(5), end=None) -> p0: [5, 10) p1: [10, 20) p2: [20, 30) p3: [30, +inf)

partition_count == split_points.len() + 1

Is there any reason not to make the bounds required? Range partitioning will always be originated by leaf nodes, like Parquet scans, and isn't there safe to assume that the ranges are always going to be bounded? If that's the case, we could even go as far as just baking the start and end bounds in the split points themselves:

Range([5, 10, 20, 30, 35]) -> p0: [5, 10) p1: [10, 20) p2: [20, 30) p3: [30, 35)
Range([-inf, 10, 20, 30, 35]) -> p0: [-inf, 10) p1: [10, 20) p2: [20, 30) p3: [30, 35)
Range([5, 10, 20, 30, +inf]) -> p0: [5, 10) p1: [10, 20) p2: [20, 30) p3: [30, +inf)

partition_count == split_points.len() - 1 // notice how this changed

+-inf can even be something that could be explicitly stated in the first and/or last split point.

I feel like this should probably have been discussed before, but I don't see any reference in any of the PRs mentioned linked in this one.

To @jayshrivastava's point in the FAQ:

Some types don't have a max or a reasonable value ex. what's the max of a string? "zzzzzz"? For variable length types too

Then, strings split points will just never have a +-inf bound, there will always be a value required, which at first sight sounds like a fair assumption. Range will always be originated from leaf nodes, and I'd expect the leaf nodes to always be able to provide an appropriate value for all split points in the range.

@stuhood stuhood Nov 5, 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.

Then, strings split points will just never have a +-inf bound, there will always be a value required, which at first sight sounds like a fair assumption. Range will always be originated from leaf nodes, and I'd expect the leaf nodes to always be able to provide an appropriate value for all split points in the range.

What are the bounds for an empty dataset? You'd have to invent something.

min/max involve a ton of special cases, but especially when they're required.

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.

Aren't you in the same situation with the current spec? For an empty dataset, the current spec implicitly says its bounds are -inf +inf, this would just set it explicitly.

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.

But as you say, there is no +inf for strings or bytes.

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.

And even if there were, we should be very sure to match the RowConverter and compare_rows order for NULL, NaN, -INF, etc

@gabotechs gabotechs Nov 6, 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.

But as you say, there is no +inf for strings or bytes.

But isn't this still a challenge with the current spec, it's just that now it's implicit.

🤔 In the case of an empty dataset, wouldn't it be convenient to represent that somehow rather than returning a (-inf, +inf)? For example, if you know at planning time there's 0 rows, just replace that node with an EmptyExec, and make Range partitioning over an empty dataset irrepresentable.

In any case, my suggestion here is mainly about cosmetics, I don't think it's really filling any gap not accounted for already in this PR

@jayshrivastava

jayshrivastava commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

@mbutrovich I've thought about partitioning satisfaction and layouts a little bit. I this is the main issue to address before merging.

Background

  • symmetric_join_output_partitioning sets the output partitioning of the join based on the join type only (does not care about split points, distribution etc.)
  • EnforceDistribution optimizer rule
    • The join declares input_distribution_requirements, so we assert the inputs are key partitioned. However, we don't check split points, bounds, or partition count. We insert a RepartitionExec if this failes
    • enforce_distribution_relationships check that all join inputs are co-partitioned
      • adapt - Imo this should be called adapt_schema and modify the schema temporarily to check co-partitioned ness. I have reverted the previous change were we strip the bounds.
      • has_same_layout - This is the main thing that checks partition count, split points etc. If the layout is not the same, we insert a RepartitionExec

So the question is - now that there's bounds, are there soundness / correctness issues?

Join Example

For the most part, everything in vanilla datafusion is correct. The symmetric_join_output_partitioning does the heavy lifting, choosing the right bounds as needed.

The only suspicious case is this:

HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, a@0)]
  RepartitionExec: partitioning=Range([a@0 ASC], [(10), (20), (30)], 4), input_partitions=1
    DataSourceExec: partitions=1, partition_sizes=[1]
  DataSourceExec: partitions=4, partition_sizes=[1, 1, 1, 1]

Left partitioning: Range([a@0 ASC], [(10), (20), (30)], 4)
Right partitioning: Range([a@0 ASC], [(10), (20), (30)], 4, start=(0), end=(35))
Output partitioning: Range([a@1 ASC], [(10), (20), (30)], 4, start=(0), end=(35))

We repartition without bounds into a join that has bounds. In this scenario, because the operator is a join, it's expected that the join drops rows outside the bounds. The key thing to note is bounds are not considered by the optimizer rule

  • input_distribution_requirements enforces distributions, which are not aware of partition counts, split points, nor bounds
  • has_same_layout - considers everything but bounds

The ExecutionPlan must truncate to the bounds.

Custom Plan

Say I create a custom plan which enforces an input distribution, I can introduce an issue where we emit rows outside the bounds. The plan node itself has to override its partitioning to be Range with bounds. EnforceDistribution does not enforce bounds so the plan node emits data outside the bounds.

Output partitioning: Range([a@0 ASC], [(20)], 2, start=(10), end=(30))
Input partitioning: Hash([a@0], 2)

TestProjectionExec // configures the `input_distribution_requirements` to be key partitioned on `a`
  RepartitionExec: partitioning=Hash([a@0], 2), input_partitions=2
    DataSourceExec: partitions=2, partition_sizes=[1, 1]

This emits rows outside of [10, 30]. This is a real problem.

Ideas

My two best ideas are as follows:

  1. Leave this as is, defer to the operator to handle bounds. If an operator declares Partitioning::Range with bounds and its input doesn't have bounds, the operator should handle this. This is reasonable because the plan node has to opt in. There wouldn't be a correctness issue without that. This is currently true for all joins and works fine.

  2. We could make has_same_layout consider bounds, but for joins, the operator handles rows outside the bounds already. Updating has_same_layout to return false when the input has no bounds and the output is bounded would insert repartitions which are unnecessary. For example here, we don't need to repartition the left

HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, a@0)]
    DataSourceExec: partitions=4, partition_sizes=[1, 1, 1, 1]
    DataSourceExec: partitions=4, partition_sizes=[1, 1, 1, 1]

Left partitioning: Range([a@0 ASC], [(10), (20), (30)], 4)
Right partitioning: Range([a@0 ASC], [(10), (20), (30)], 4, start=(0), end=(35))
Output partitioning: Range([a@1 ASC], [(10), (20), (30)], 4, start=(0), end=(35))

If I were to make has_same_layout return false on a bounds mismatch, I would add a FilterExec like so just to be safe in lieu of a repartition (an in some cases you would need both a repartition and filter).

FilterExec: 0 <= a AND a < 30

I wouldn't mind putting that in this PR if it makes sense.

  1. This one is related to Add Range Layout Replationships to Avoid Repartitions #25483 and is a bigger change. I would say we can do this whenever work on Add Range Layout Replationships to Avoid Repartitions #25483 begins and not put it in this PR.

The solution there would be to probably add partitions to input_distribution_requirements (or RangeLayoutRelationshipKind as the issue proposes).

This change feels like it may be overdue. I'm a new contributor, but I feel like the distributions were added for things like aggregations, where you only care that there's a distinct value in each partition but you don't care about anything else. We're kind of piggybacking on that mechanism for strong partitioning guarantees, like for co-partitioned joins where the actual set of values in each partition matters.

I'm going to push a commit with some tests which show the above examples.

Please let me know what you think!

@jayshrivastava
jayshrivastava force-pushed the js/range-partition-bounds branch from daec196 to d234a45 Compare November 6, 2026 22:13
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Nov 6, 2026
@jayshrivastava
jayshrivastava force-pushed the js/range-partition-bounds branch from d234a45 to e0947f9 Compare November 6, 2026 22:14

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

Thanks @jayshrivastava for putting this together and working through all of the join/interleave cases, and thanks @mbutrovich, @avantgardnerio, and @gabotechs for digging into the design tradeoffs here!

I'm marking this "Request Changes" mainly to hold off on baking start_bound / end_bound into RangePartitioning's public API before we settle the model. That said, relaxing validate_range_split_points from < to <= seems fine and unblocks @gabotechs's empty FileGroup case and @avantgardnerio's repeated KLL cuts on skewed keys: @jayshrivastava would you be open to splitting that < to <= change into its own small PR so we can land it right away?

On start_bound / end_bound: reading through the discussion here, I think the reason equality, can_interleave, join output partitioning, and custom operators are getting tricky is that RangePartitioning is starting to carry statistics about the data in the partitions, rather than strictly the function mapping rows to partitions (ordering + split_points).

To be fair, we already started down that road with samples in #24766 (which I filed #24712 for originally!). samples is a quantile summary of the data, and start_bound / end_bound are min/max extrema of the data:

  • Attaching samples to RangePartitioning in #24766 was what forced us to split PartialEq from has_same_layout in the first place. For example, InterleaveExec (datafusion/physical-plan/src/union.rs) already has to reject interleaving two plans with identical split_points if only one carries samples, because keeping one child's samples on output_partitioning() would lie about the combined distribution.
  • Adding start_bound / end_bound pushes that same split further: joins have to compute/drop bounds per join type, custom plans with input_distribution_requirements can silently emit rows outside their declared bounds unless FilterExecs are inserted, and #25483 has to reason about bounds alongside split points.
  • Even with start_bound / end_bound, outer bounds only describe one contiguous span, so they still can't represent interior empty partitions (say, p0 and p3 non-empty while p1 and p2 are empty after pruning or skew).

On @avantgardnerio's point:

Under the empty-partition alternative, a task advertises Range(16, ...), and which partitions are empty lives only in the source's private state. Nothing in the plan says the task's keys fall within [cut3, cut7), so no rule running in that task can use it.

I agree that the plan needs to expose both emptiness and key ranges so rules and operators can act on them... I just think per-partition Statistics (partition_statistics(Some(idx)) via StatisticsContext, which #26094 already threaded through EnforceDistribution) is the natural home for both bounds/emptiness and samples:

  • Empty partitions: if RepartitionExec (and NetworkCoalesceExec in DFD) skips spawning tasks/streams for input partitions with num_rows == Precision::Exact(0), we eliminate the empty-channel overhead for Hash and UnknownPartitioning too (datafusion-contrib/datafusion-distributed#628), handle non-contiguous empty partitions from pruning, and let every worker in DFD advertise the exact same Range(N, split_points) without falling back to UnknownPartitioning (datafusion-contrib/datafusion-distributed#782). If polling N - K empty streams inside a task ever matters at very large N, projecting leaf/reader partitions at execution time after physical optimization (like Ballista's task_builder already does) avoids that without touching Partitioning.
  • Extrema instead of start_bound / end_bound: per-partition [min, max] extrema (including ordering-keyed lexicographic extrema along the lines of #23089 / ExtendedStatistics for composite or expression sort keys) give #25483 channel pruning, dynamic filters, and join pruning the exact observed span of each partition i, without changing Partitioning equality.
  • Quantiles/sketches instead of samples: since 56.0.0 hasn't been cut yet (#24766 landed after 55.2.0), we still have a window to move samples out of RangePartitioning and into Statistics too. samples() / scale() only has two real callers in DataFusion (EnforceDistribution in datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs and RepartitionExec::repartitioned in datafusion/physical-plan/src/repartition/mod.rs), and both already hold the child Arc<dyn ExecutionPlan>. Putting quantile summaries / KLL sketches in Statistics (with passthrough/filter wiring so intermediate operators don't drop them) lets RangePartitioning go back to just { ordering, split_points }, deletes has_same_layout vs. PartialEq altogether, and lets EnforceDistribution pick Range cuts from stats even when the input is UnknownPartitioning.

One related thought on Ballista's dynamic range cuts (apache/datafusion-ballista#2364, apache/datafusion-ballista#2722) vs. streaming execution (DFD, and Ballista's new pipelined mode in apache/datafusion-ballista#2652):

  • If split points aren't known ahead of time, you can't co-partition a join with them anyway: runtime-discovered cuts are only useful after a producer stage finishes so a consumer stage can pick balanced cuts (like parallel window frames or global sort).
  • With quantiles/KLL modeled as Statistics, Ballista's AQE mode doesn't need an unresolved/pending Range partitioning in Stage 0: Stage 0 just collects KLL Statistics at runtime, and once it finishes, AQE plans Stage 1 with a concrete Partitioning::Range(split_points). Meanwhile, streaming/pipelined stages (where producer and consumer run concurrently) can use the exact same Statistics to SplitPoint logic up front from catalog/file stats.

What do folks think about:

  1. Landing < to <= split-point validation separately right now, and
  2. Keeping RangePartitioning as { ordering, split_points } while moving emptiness, per-partition extrema, and quantile samples into Statistics before 56.0.0?
    • I'd be happy to get a PR out to do this if there is agreement.

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

@jayshrivastava thanks for working through all the join and bounds cases here.

+1 to @stuhood's suggestion to land the < to <= split-point change on its own. That PR will need a routing test of its own: every RepartitionExec test here with equal split points also sets bounds (range_repartition_bounded_empty_partitions, range_repartition_rejects_keys_outside_bounds). I checked the unbounded case locally: split points [10, 10, 20] with UInt32 keys [5, 10, 15, 25] route to [5], [], [10, 15], [25]. Adding the same case with a two-column key would also cover the row router.

@avantgardnerio

Copy link
Copy Markdown
Contributor

Re: @stuhood

Landing < to <= split-point validation separately right now

+1

per-partition extrema ... into Statistics

I think Statistics represent observations of the data ("what was the lowest value we've seen"), whereas bounds represent "which values is this partition responsible for", which answers a different question. There may be values that are split points / bounds that do not exist in the data at all.

Ballista's AQE mode doesn't need an unresolved/pending Range partitioning

Because the entire Ballista job is planned as 1 DataFusion plan, then split, unresolved split points would be useful: the job-level plan could declare that it was range partitioned before the split points are resolved and the plan is sliced into task plans (after which it would work as you describe). It's not necessary for that functionality to be in this PR, but until it is available Ballista will have to stick with UnknownPartitioning(K) in such cases.

@jayshrivastava

Copy link
Copy Markdown
Contributor Author

I pulled the equal split points work in to #27384. Once that's merged, I'll give this one a rebase.

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

Labels

common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation ffi Changes to the ffi crate logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate proto Related to proto crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

partitioning: store start and end bounds in Partitioning::Range

8 participants