Skip to content

fix: plan a range RepartitionExec as a shuffle stage instead of dropping it - #2722

Open
andygrove wants to merge 6 commits into
apache:mainfrom
andygrove:fix/2680-range-repartition-shuffle
Open

andygrove wants to merge 6 commits into
apache:mainfrom
andygrove:fix/2680-range-repartition-shuffle

Conversation

@andygrove

@andygrove andygrove commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #2680.

Rationale for this change

The static planner kept only hash repartitions as stage boundaries and dropped every other RepartitionExec. Dropping a round-robin repartition only costs parallelism, but a range repartition is part of what a plan means. A plan that range-partitions and then sorts each partition (a distributed global sort) came back as partitions that were each sorted but not range-disjoint, with no error. Comet Native Executors need this for Spark's range exchanges (apache/datafusion-comet#7288).

What changes are included in this PR?

  • Planner. A RepartitionExec with Partitioning::Range is a stage boundary, like a hash one, so partition i of the consuming stage reads exactly range i. Round-robin repartitions are still dropped. A range repartition that must preserve its input order (preserve_order()) returns an error: a shuffle cannot merge sorted inputs, and planning it anyway would return partitions that are range-disjoint but no longer sorted.
  • Writer. SortShuffleWriterExec accepts Partitioning::Range and routes rows with DataFusion's RangeExpr, which shares its routing function with the BatchPartitioner behind RepartitionExec, so the shuffle puts every row where the in-process operator would have. Everything after routing (buffering, spilling, the consolidated file and its index) is shared with the hash shuffle.
  • Per-task restriction. A ShuffleReaderExec with a range partitioning slices its split points along with its partitions, replacing the TODO and the error in task_builder.rs. A slice that does not ascend (a retried partition queued ahead of a later one) or is empty reports UnknownPartitioning rather than failing the task: no split points describe it, and UnknownPartitioning claims nothing about which rows each partition holds. (DataFusion's partitioned hash-join dynamic filters do read the probe side's reported partitioning, but Ballista turns dynamic filter pushdown off by default.)
  • Wire format. A new partitioning field on SortShuffleWriterExecNode carries the range partitioning. Hash stages keep using output_partitioning, so a hash plan is encoded as before. UnresolvedShuffleExec and ShuffleReaderExec already carried a general Partitioning.

Two things this PR deliberately does not do:

  • It does not use RangeShuffleWriterExec / RangeShuffleReaderExec, which the issue mentions. RangeShuffleWriterExec requires an ordered producer (try_new rejects a child with no output ordering) and its reader merges sorted runs, while the issue's own example sorts after the repartition. The range edge reuses the sort-shuffle writer, as the hash edge does.
  • It does not change the adaptive planner's own rules or the pipelined exchange operators. Since feat: run submitted physical plans through the adaptive planner #2682, submitted physical plans go through the adaptive planner by default, and its PinSubmittedShufflesRule already pins a range repartition as an exchange. On main that job fails with unsupported shuffle output partitioning, because only hash stages get a writer. The writer change here makes it run, so the adaptive path is fixed too. The pipelined reader still refuses to restrict a range partitioning, and nothing plans pipelined jobs yet.

RangeExpr extracts a ScalarValue per row, so range routing will be slower than hash routing on large inputs (the same cost as DataFusion's own RepartitionExec). I have not benchmarked it.

Tests. Besides unit tests for routing (with BatchPartitioner as an oracle), serde and reader restriction, planner/range_shuffle_tests.rs plans a range-partition-then-sort and runs the stages for real, without sockets: it restricts each task's plan, ships it through the codec, and runs the writer and reader. It checks that the final partitions are range-disjoint, sorted, and hold every row exactly once, with one task per partition, with tasks owning several partitions, and with a slice out of key order. With the planner change reverted, those tests fail with the symptom in the issue. Two tests in ballista/client/tests/physical_plan_submission.rs submit the same shape to a standalone cluster with max_partitions_per_task = 1, one per planner. The job's output partitions come back in the order their tasks finished, so they check every key exactly once, each partition sorted, and no two partitions' key ranges overlapping. The static one fails with overlapping partitions when the planner change is reverted, and the adaptive one fails with unsupported shuffle output partitioning when the writer change is reverted.

Other commits. Besides the fix, three commits add entries to the 56.0.0 upgrade guide, move the new proto field to number 11 (see the overlap with #2723 below), and simplify the routing code and trim the tests and comments after a review pass. The last commit adds the standalone-cluster tests.

Overlap with other open PRs.

Are there any user-facing changes?

No SQL-visible changes. A physical plan submitted with a range RepartitionExec now returns correct results instead of range-overlapping partitions, and a range repartition that must preserve its order is rejected with an error.

The generated SortShuffleWriterExecNode struct gains a public partitioning field, so code that builds it with a struct literal needs partitioning: None. The only such use in this repo is updated. Both changes are described in the 56.0.0 upgrade guide, and the PR carries the api-change label for the struct field.

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

Looks good. I like that the routing test checks against DataFusion's BatchPartitioner. Since the end-to-end tests run without sockets, could we add one standalone-cluster test in ballista/client/tests that submits a range-repartition plan with a split stage (e.g. max_partitions_per_task = 1) and checks the output is globally sorted? A rough perf number for range vs hash routing would be nice too, but not blocking.

andygrove and others added 5 commits November 9, 2026 17:27
…ing it

The static planner kept only hash repartitions as stage boundaries and
dropped every other RepartitionExec. That is harmless for round-robin,
which only spreads rows, but a range repartition is part of what a plan
means. A plan that range-partitions and then sorts each partition (a
distributed global sort) came back as partitions that were each sorted
but not range-disjoint, with no error.

A RepartitionExec with Partitioning::Range is now a stage boundary. The
producer stage ends in a SortShuffleWriterExec that routes rows with
DataFusion's own RangeExpr (the function RepartitionExec applies), so
the consumer's partition i reads exactly range i. The writer shares
everything after routing with the hash shuffle: buffering, spilling, the
consolidated file and its index. The range travels in a new field of
SortShuffleWriterExecNode; hash stages keep using the old one.

Restricting a range-partitioned ShuffleReaderExec to a task's partitions
now slices the split points along with them, instead of failing. A slice
that does not ascend (a retried partition queued ahead of a later one)
or is empty has no range partitioning to report, so it reports
UnknownPartitioning rather than failing the task.

A range repartition that must preserve its input order is rejected with
an error: a shuffle cannot merge sorted inputs, and planning it anyway
would return partitions that are range-disjoint but no longer sorted.

RangeShuffleWriterExec and RangeShuffleReaderExec are not used. They
need an ordered producer, while the repartition here is followed by the
sort.

Closes apache#2680
A range RepartitionExec in a submitted physical plan is now planned as a
shuffle instead of being dropped, one that must preserve its order is
refused, and SortShuffleWriterExecNode gains a field.
apache#2723 adds partition_id_routing to the same message as field 10, and two
fields cannot share a number. 11 leaves 10 to it, and putting the field
next to output_partitioning keeps it clear of the fields that PR appends
at the end of the message.
- RowRouter takes the partitioning by value and no longer carries a partition
  count; the hash and range paths share one bucket reset.
- Reword two comments that overstated things. RangeExpr shares
  BatchPartitioner's routing function (it is not "the function RepartitionExec
  applies"), and a restricted reader's partitioning is read by partitioned
  hash-join dynamic filters, which Ballista disables by default.
- Drop overlapping tests and duplicated helpers, reuse roundtrip_plan in the
  serde tests, and shorten comments and the upgrade-guide entries.
@andygrove
andygrove force-pushed the fix/2680-range-repartition-shuffle branch from 94ccab6 to fa5fe79 Compare November 10, 2026 00:39
@andygrove

Copy link
Copy Markdown
Member Author

@coderfender Added in fa5fe79, under both the static and the adaptive planner, with max_partitions_per_task = 1. One wrinkle: the job's output partitions come back in the order their tasks finished, so the concatenated stream isn't something we can check for global order. The test checks what makes it a global sort instead: every key exactly once, each partition sorted, and no two partitions' key ranges overlapping. With the planner change reverted, the static test fails with overlapping partitions.

Writing it turned up something useful. Since #2682 landed, submitted plans go through the adaptive planner by default, and it pins a range repartition as an exchange. On main that job fails with unsupported shuffle output partitioning, and this PR's writer change is what makes it work, so the adaptive test covers that path too. I've rebased onto main as well. No perf numbers yet, I'll leave that for a follow-up.

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

This seems complimentary to the existing AQE range partitioning, and necessary as you say since DataFusion plans now have Partitioning::Range, which Ballista needs to support.

@andygrove

Copy link
Copy Markdown
Member Author

Good question. Ballista never computes the split points for Comet. Spark does, in the driver, before the plan reaches Ballista.

When Spark builds the shuffle for a range exchange (ShuffleExchangeExec.prepareShuffleDependency), it constructs a RangePartitioner. That constructor runs a separate small Spark job over the child. The job reservoir-samples the sort keys, sorts the sample, and picks numPartitions - 1 boundary rows, which are the split points. They come from sampling the data and not from static statistics. Comet's native shuffle already does this: CometShuffleExchangeExec builds the same RangePartitioner over the sampled sort keys and reads rangeBounds out of it. They go into the boundary_rows field of Comet's range partitioning proto.

Ballista is not called repeatedly. The sampling is a separate Spark job, and the Ballista job is submitted once.

Comet Native Executors doesn't hand Ballista a range partitioning yet. Today the driver sends none of that, and the cluster side turns a range exchange into a coalesce, so the consuming stage reads it as one partition. That sorts correctly but runs in a single task. This PR is what lets us stop doing that, because the planner no longer drops Partitioning::Range. Getting the bounds into the plan is the driver's job. It would run the sampling job and put the result into the RepartitionExec's Partitioning::Range expressions. We haven't built that part, so I can't say yet how the sampling job gets its rows.

This also means Ballista only needs to route against fixed split points here. It doesn't need RuntimeStatsExec for this path. I think that's why it complements your AQE work. Your AQE path derives the split points from runtime stats. This path accepts a plan whose split points are already decided.

…ss family

The standalone scheduler binds localhost:0, which can resolve to ::1,
while a push-staged standalone executor bound its gRPC listener on
127.0.0.1:0. The two address families can be handed the same port
number, and the scheduler then dials the executor's registered
localhost:<port> and reaches itself, failing launches with Unimplemented.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-change documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static planner drops a RepartitionExec with range partitioning instead of planning a range shuffle

3 participants