Skip to content

feat: allow scaling RangePartitioning - #24766

Open
goutamadwant wants to merge 13 commits into
apache:mainfrom
goutamadwant:feat/range-partitioning-scaling
Open

goutamadwant wants to merge 13 commits into
apache:mainfrom
goutamadwant:feat/range-partitioning-scaling

Conversation

@goutamadwant

@goutamadwant goutamadwant commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Distributed and adaptive planners need to change a range-partitioned plan's task count without losing its higher-resolution boundary sample. The optimizer should use a compatible range layout where possible while preserving the requested parallelism when sample capacity is insufficient.

What changes are included in this PR?

  • Add validated sample-backed construction, sample/current/maximum partition-count accessors, and scale(usize) -> Option<Self>. Zero and above-capacity requests return None.
  • Retain samples across scaling, projection, repartition, join rewrites, protobuf, and FFI round trips. Keep structural equality for interleave safety and use has_same_layout for effective-layout comparisons.
  • Integrate scaling into EnsureRequirements, recheck key requirements after singleton scaling, and fall back to key partitioning at the requested parallelism when scaling is unsupported. Preserve native reference selection and the larger-input preference.
  • Let RepartitionExec::repartitioned scale supported ranges with fresh runtime state and metrics, retaining ordering and batch-size settings. Remove the optimizer's redundant outer Option.
  • Deprecate unchecked new for 56.0.0, retain validated exact construction and split_points(), and document sample sizing and upgrade requirements.
  • Preserve effective boundaries in protobuf field 2 for older readers, and validate the added sample/count representation against those boundaries.

What is the testing strategy for this PR?

Coverage includes count limits, sample retention, key and sort-option compatibility, structural versus effective-layout equality, interleave safety, sample-preserving rewrites, protobuf/FFI round trips, and aggregate/join results with NULLs.

The new exchange regression fails before the follow-up fix and passes afterward. It initializes the original exchange, scales 2 -> 4 -> 1 -> 4, and checks exact row routing, ordering, retained samples, batch configuration, and independent execution state and metrics. The 23 range optimizer integration regressions also pass.

Validation with the pinned Rust 1.98.1 toolchain:

  • Formatting, all-target/all-feature Clippy with warnings denied, and the complete ./dev/rust_lint.sh suite pass.
  • The extended workspace suite passes 12,022 Rust tests, with eight ignored, and all 522 SQL logic-test files.
  • All 155 all-feature FFI tests pass.

Performance caveat: six existing range benchmarks were compared with base 925d7f8ffd using separate Rust 1.98.1 release-nonlto builds and alternating runs. The eight-partition integer routing case was 13.5% slower; the other five differed by -1.1% to +2.2%. A separate before/after check of that case found this follow-up within 0.3% of pre-follow-up head 1d2d59e699, with both about 18% slower than the base in that run. This indicates an existing full-PR performance concern, not a measured slowdown introduced by this follow-up. Its cause remains unverified; these local results do not establish performance parity with main.

Are there any user-facing changes?

Yes. RangePartitioning gains sample-backed construction and scaling APIs. scale returns Option<Self> rather than a public error type. Unchecked new is deprecated; validated exact construction remains supported. Unsupported optimizer scaling falls back to key partitioning rather than retaining lower parallelism.

FFI_RangePartitioning changes layout and the generated PhysicalRangePartitioning protobuf struct gains fields. These API/ABI changes require the api change label. This targets main, not a patch release. FFI consumers must rebuild against the compatible release. Existing protobuf payloads remain readable.

@github-actions github-actions Bot added physical-expr Changes to the physical-expr crates ffi Changes to the ffi crate physical-plan Changes to the physical-plan crate labels Aug 29, 2026
@github-actions

github-actions Bot commented Aug 29, 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 v55.1.0 (current)
       Built [  61.996s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.039s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  62.586s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.039s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.950s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 128.167s] datafusion
    Building datafusion-ffi v55.1.0 (current)
       Built [  56.685s] (current)
     Parsing datafusion-ffi v55.1.0 (current)
      Parsed [   0.068s] (current)
    Building datafusion-ffi v55.1.0 (baseline)
       Built [  58.234s] (baseline)
     Parsing datafusion-ffi v55.1.0 (baseline)
      Parsed [   0.071s] (baseline)
    Checking datafusion-ffi v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.343s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 117.358s] datafusion-ffi
    Building datafusion-physical-expr v55.1.0 (current)
       Built [  31.173s] (current)
     Parsing datafusion-physical-expr v55.1.0 (current)
      Parsed [   0.054s] (current)
    Building datafusion-physical-expr v55.1.0 (baseline)
       Built [  30.762s] (baseline)
     Parsing datafusion-physical-expr v55.1.0 (baseline)
      Parsed [   0.057s] (baseline)
    Checking datafusion-physical-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.466s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure type_method_marked_deprecated: type method #[deprecated] added ---

Description:
A type 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.50.0/src/lints/type_method_marked_deprecated.ron

Failed in:
  method datafusion_physical_expr::RangePartitioning::new in /home/runner/work/datafusion/datafusion/datafusion/physical-expr/src/partitioning.rs:232

     Summary semver requires new minor version: 0 major and 1 minor checks failed
    Finished [  63.755s] datafusion-physical-expr
    Building datafusion-physical-optimizer v55.1.0 (current)
       Built [  45.865s] (current)
     Parsing datafusion-physical-optimizer v55.1.0 (current)
      Parsed [   0.025s] (current)
    Building datafusion-physical-optimizer v55.1.0 (baseline)
       Built [  46.598s] (baseline)
     Parsing datafusion-physical-optimizer v55.1.0 (baseline)
      Parsed [   0.025s] (baseline)
    Checking datafusion-physical-optimizer v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.161s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  94.161s] datafusion-physical-optimizer
    Building datafusion-physical-plan v55.1.0 (current)
       Built [  41.234s] (current)
     Parsing datafusion-physical-plan v55.1.0 (current)
      Parsed [   0.189s] (current)
    Building datafusion-physical-plan v55.1.0 (baseline)
       Built [  41.171s] (baseline)
     Parsing datafusion-physical-plan v55.1.0 (baseline)
      Parsed [   0.186s] (baseline)
    Checking datafusion-physical-plan v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   1.059s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  85.832s] datafusion-physical-plan
    Building datafusion-proto v55.1.0 (current)
       Built [  60.555s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.019s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  60.214s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.020s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.155s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 122.811s] datafusion-proto
    Building datafusion-proto-models v55.1.0 (current)
       Built [  27.045s] (current)
     Parsing datafusion-proto-models v55.1.0 (current)
      Parsed [   0.147s] (current)
    Building datafusion-proto-models v55.1.0 (baseline)
       Built [  26.814s] (baseline)
     Parsing datafusion-proto-models v55.1.0 (baseline)
      Parsed [   0.150s] (baseline)
    Checking datafusion-proto-models v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   2.745s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field PhysicalRangePartitioning.sample_point in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2440
  field PhysicalRangePartitioning.partition_count in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2443
  field PhysicalRangePartitioning.sample_point in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2440
  field PhysicalRangePartitioning.partition_count in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2443

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  58.502s] datafusion-proto-models

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Aug 29, 2026
@codecov-commenter

codecov-commenter commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.55755% with 110 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.42%. Comparing base (925d7f8) to head (a4abf20).
⚠️ Report is 21 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-expr/src/partitioning.rs 86.84% 23 Missing and 27 partials ⚠️
datafusion/proto-models/src/generated/pbjson.rs 0.00% 26 Missing ⚠️
datafusion/physical-plan/src/repartition/mod.rs 88.28% 2 Missing and 13 partials ⚠️
datafusion/ffi/src/physical_expr/partitioning.rs 82.05% 3 Missing and 4 partials ⚠️
datafusion/physical-plan/src/union.rs 78.26% 1 Missing and 4 partials ⚠️
datafusion/physical-plan/src/joins/utils.rs 85.00% 3 Missing ⚠️
datafusion/ffi/src/plan_properties.rs 75.00% 1 Missing and 1 partial ⚠️
...er/src/ensure_requirements/enforce_distribution.rs 93.75% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24766      +/-   ##
==========================================
+ Coverage   82.38%   82.42%   +0.03%     
==========================================
  Files        1138     1138              
  Lines      434309   436009    +1700     
  Branches   434309   436009    +1700     
==========================================
+ Hits       357803   359362    +1559     
- Misses      54875    54903      +28     
- Partials    21631    21744     +113     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Thanks @goutamadwant, there is a suggestion:

RangePartitioning::PartialEq compares only ordering + effective split_points, but samples is not derivable from split_points and is exactly the state scale() reads. So a == b does not imply a.scale(k) == b.scale(k), and doesn't even imply both succeed — your own test asserts try_new_with_samples(ord, [10..90], 4) == try_new(ord, [30,50,70]), yet the first scales to 10 and the second errors at 4.

There's already a consumer that turns this into wrong results. can_interleave gates on partition == *reference:

// datafusion/physical-plan/src/union.rs:931
matches!(reference, Partitioning::Hash(_, _) | Partitioning::Range(_))
    && inputs
        .map(|plan| plan.borrow().output_partitioning().clone())
        .all(|partition| partition == *reference)

and InterleaveExec::compute_properties then adopts one input's metadata for the whole output:

// datafusion/physical-plan/src/union.rs:698
let output_partitioning = inputs[0].output_partitioning().clone();

Concretely: input A = try_new_with_samples(ord, [10,20,...,90], 4), input B = try_new(ord, [30,50,70]). They compare equal, interleaving is allowed (correct — the effective boundaries do match), and the InterleaveExec output now advertises max_partition_count() == 10. A distributed planner — the use case this PR is for — calls scale(10) on that output and gets [10,20,...,90], boundaries B's rows were never placed against. Because range partitioning is a declared, unvalidated property, that's silently wrong rows per partition, not an error.

Suggest making equality structural:

-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
 pub struct RangePartitioning {
     ordering: LexOrdering,
     samples: Arc<[SplitPoint]>,
     split_points: Arc<[SplitPoint]>,
     partition_count: usize,
 }
@@
-impl PartialEq for RangePartitioning {
-    fn eq(&self, other: &Self) -> bool {
-        self.ordering == other.ordering && self.split_points == other.split_points
-    }
-}

The one place that genuinely wants effective-boundary comparison already spells it out and is unaffected:

// datafusion/physical-plan/src/distribution_requirements.rs:348
(Partitioning::Range(left), Partitioning::Range(right)) => {
    left.split_points() == right.split_points()
        && ...
}

so co-partitioned joins keep the permissive behavior; only can_interleave gets stricter, which is the conservative direction. test_range_partitioning_equality_uses_effective_split_points would then need to flip to assert_ne!.

If you'd rather keep permissive equality, the alternative is for InterleaveExec::compute_properties to reduce the output to the coarsest sample set common to all inputs rather than inheriting inputs[0]'s — but that's more machinery for the same guarantee.

@@ -296,6 +374,44 @@ impl Display for RangePartitioning {

@jayzhan211 jayzhan211 Aug 30, 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.

nit: Range([a@0 ASC], [(30), (50), (70)], 4) is identical for a partitioning that can scale to 10 and one that can't scale past 4. Since EXPLAIN output is the primary debugging surface for partitioning bugs, appending the max when it differs (e.g. , max 10) would pay for itself.

@stuhood

stuhood commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for working on this!

Before we actually land this, there is a little bit more work that I should do to prove that #24712 is actually sufficient to solve the case that I'm examining... could we hold off for a few days, so that I can update the ticket if needed?

@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) labels Sep 14, 2026
@goutamadwant

Copy link
Copy Markdown
Contributor Author

Thanks @goutamadwant, there is a suggestion:

RangePartitioning::PartialEq compares only ordering + effective split_points, but samples is not derivable from split_points and is exactly the state scale() reads. So a == b does not imply a.scale(k) == b.scale(k), and doesn't even imply both succeed — your own test asserts try_new_with_samples(ord, [10..90], 4) == try_new(ord, [30,50,70]), yet the first scales to 10 and the second errors at 4.

There's already a consumer that turns this into wrong results. can_interleave gates on partition == *reference:

// datafusion/physical-plan/src/union.rs:931
matches!(reference, Partitioning::Hash(_, _) | Partitioning::Range(_))
    && inputs
        .map(|plan| plan.borrow().output_partitioning().clone())
        .all(|partition| partition == *reference)

and InterleaveExec::compute_properties then adopts one input's metadata for the whole output:

// datafusion/physical-plan/src/union.rs:698
let output_partitioning = inputs[0].output_partitioning().clone();

Concretely: input A = try_new_with_samples(ord, [10,20,...,90], 4), input B = try_new(ord, [30,50,70]). They compare equal, interleaving is allowed (correct — the effective boundaries do match), and the InterleaveExec output now advertises max_partition_count() == 10. A distributed planner — the use case this PR is for — calls scale(10) on that output and gets [10,20,...,90], boundaries B's rows were never placed against. Because range partitioning is a declared, unvalidated property, that's silently wrong rows per partition, not an error.

Suggest making equality structural:

-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
 pub struct RangePartitioning {
     ordering: LexOrdering,
     samples: Arc<[SplitPoint]>,
     split_points: Arc<[SplitPoint]>,
     partition_count: usize,
 }
@@
-impl PartialEq for RangePartitioning {
-    fn eq(&self, other: &Self) -> bool {
-        self.ordering == other.ordering && self.split_points == other.split_points
-    }
-}

The one place that genuinely wants effective-boundary comparison already spells it out and is unaffected:

// datafusion/physical-plan/src/distribution_requirements.rs:348
(Partitioning::Range(left), Partitioning::Range(right)) => {
    left.split_points() == right.split_points()
        && ...
}

so co-partitioned joins keep the permissive behavior; only can_interleave gets stricter, which is the conservative direction. test_range_partitioning_equality_uses_effective_split_points would then need to flip to assert_ne!.

If you'd rather keep permissive equality, the alternative is for InterleaveExec::compute_properties to reduce the output to the coarsest sample set common to all inputs rather than inheriting inputs[0]'s — but that's more machinery for the same guarantee.

@jayzhan211 Switched RangePartitioning to structural equality and added an interleave regression for matching effective boundaries with different retained samples. Both input orders are covered, so interleave cannot inherit unsupported scaling capacity from either input.

@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 for working on this!

I've integrated it in datafusion-distributed and ParadeDB, and it seems to be working well.

Comment on lines +305 to 320
/// Returns this range partitioning scaled to `target_partitions`.
///
/// Scaling retains the original samples, so a range partitioning that was
/// scaled down can later be scaled back up to [`Self::max_partition_count`].
pub fn scale(&self, target_partitions: usize) -> Result<Self> {
validate_range_partition_count(target_partitions, self.max_partition_count())?;
if target_partitions == self.partition_count {
return Ok(self.clone());
}
Ok(Self {
ordering: self.ordering.clone(),
samples: Arc::clone(&self.samples),
split_points: downsample_split_points(&self.samples, target_partitions),
partition_count: target_partitions,
})
}

@stuhood stuhood Sep 14, 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.

So, the case where we don't have enough samples to scale up to a particular target_partition count should be treated more like an expected case rather than a failure. Ideally we would always have enough samples to do this... but in cases where we don't (small tables, etc), it is definitely a recoverable case, and callers should convert the Partitioning::Range into something else... e.g. Partitioning::Unknown.

So rather than returning Result<Self>, maybe this should return Option<Self>? Alternatively, it could return a Result<Self, ScalingError> (name TBD) that allows the caller to differentiate failure cases.

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.

@stuhood changed scale to return a typed RangePartitioningScaleError, distinguishing InsufficientSamples from ZeroPartitions. EnsureRequirements retains a satisfying range when capacity is insufficient; other callers can choose their own fallback without parsing error strings.

Comment on lines +251 to +261
/// Creates sample-backed range partitioning and validates the sample shape,
/// ordering, and target partition count.
///
/// `partition_count` must be at least one and no larger than
/// `samples.len() + 1`. When it is smaller than that maximum, the samples
/// are evenly down-sampled to derive the effective split points.
pub fn try_new_with_samples(
ordering: LexOrdering,
samples: Vec<SplitPoint>,
partition_count: usize,
) -> Result<Self> {

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 looks good. But I think that we should go ahead and deprecate the old constructor, and have all existing callers switch to a method like RangePartitioning::try_new_with_samples. RangePartitioning::new will create something which cannot be scaled, and that's much less useful.

In terms of the number of samples for callers to provide to try_new_with_samples: we might want to recommend something like K * target_partitions, where K is some value that we expect will give us enough flexibility to actually scale things for the relevant number of 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.

@stuhood deprecated the unchecked new constructor for 56.0.0 and migrated its internal callers to try_new_with_samples. Validated try_new remains supported and delegates to the sample-backed constructor. Added capacity-sizing guidance and an oversampling example, including the small-input limitation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't know if deprecating is needed rather than it jsut being an invariant. It seems we can alsways derive the samples form split points.

@stuhood do you think there is a use case for having a contructor that does not return a Result? I could see it if a use case vlaidated themselves and is really trying to squeeze perf

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.

@stuhood do you think there is a use case for having a contructor that does not return a Result? I could see it if a use case vlaidated themselves and is really trying to squeeze perf

Hm, maybe... but I can't think of any cases where you'd be creating RangePartitioning instances in a tight loop.

/// NOTE: Optimizer and execution behavior for this partitioning is intentionally
/// not implemented and will be introduced incrementally. See
/// <https://github.com/apache/datafusion/issues/22395>.
#[derive(Debug, Clone, PartialEq)]

@stuhood stuhood Sep 14, 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.

The fact that PartialEq is derived here is going to confuse callers: enforce_distribution_relationships uses equality to check whether two RangePartitioning instances are compatible. The samples should not actually be included in that equality check: only the split_points.

There are two schools of thought on that, and I don't know which DataFusion prefers:

  • School 1: PartialEq should always represent structural equality, and so should basically always be derived. Other definitions of equality should be provided by other methods.
  • School 2: PartialEq should be the most useful definition of equality, even if that is not structural.

I expect that @gene-bordegaray has an opinion on this one.

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.

sure @stuhood cc @gene-bordegaray kept structural PartialEq for InterleaveExec safety and added has_same_layout to compare ordering and effective boundaries. The optimizer now uses that comparison. A three-input regression fails before the change and passes afterward in both sampled/exact orientations: compatible inputs retain their own samples without another exchange.

Comment on lines +216 to +219
/// Effective boundaries for the current partition count.
split_points: Arc<[SplitPoint]>,
/// Number of effective partitions.
partition_count: usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The partition_count can be derived from split_points.len() + 1, so it doesn't need to be stored.

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.

makes sense @stuhood removed the stored partition_count field; it is now derived from split_points.len() + 1. Protobuf and FFI still retain the effective count needed to reconstruct the selected layout from the stored samples.

stuhood added a commit to paradedb/datafusion-distributed that referenced this pull request Sep 14, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation proto Related to proto crate labels Sep 15, 2026
@jayzhan211

Copy link
Copy Markdown
Contributor

@goutamadwant,

This changes planning policy beyond the feature. When scale() returns InsufficientSamples, the child is now left at its current partition count instead of being key-repartitioned to target_partitions as on main. That applies to every key requirement, not only co-partitioned ones, so e.g. an aggregate over a 3-way exact range source now runs 3-way on a 16-core machine (range_requirement_scales_within_sample_resolution pins target 7 -> 3 partitions with no exchange).

We already have a knob for "keep a satisfying layout instead of increasing parallelism": preserve_file_partitions / preserve_satisfying_file_partitioning a few lines above. This change makes the disabled setting a no-op for range sources, which is why range_satisfaction_config_matrix had to flip (NOT_MET, DISABLED, GREATER) from [Hash, Hash, Hash] to [Reuse, Hash, Hash] and slt TEST 13 lost both exchanges and its 5-way parallelism.

It can also be strictly worse than main for joins. In range_preservation_prefers_larger_unscalable_input the larger side is a 3-way exact range and the other side is already a native 4-way range at target. main: one Range(4) exchange, 4-way join. This PR: one Range(3) exchange, 3-way join. Same exchange count, less parallelism. The .or_else tie-break only covers the unknown-size tie, not the strictly-larger case.

Suggest keeping main's behaviour on InsufficientSamples and dropping preserved_unscalable_range plus the tie-break:

-                                    Err(RangePartitioningScaleError::InsufficientSamples { .. }) => {
-                                        preserved_unscalable_range = true;
-                                        None
-                                    }
+                                    // Samples cannot support the target parallelism:
+                                    // fall back to key repartitioning as before.
+                                    Err(RangePartitioningScaleError::InsufficientSamples { .. }) => Some(
+                                        requirement
+                                            .clone()
+                                            .create_partitioning(target_partitions),
+                                    ),

and restoring the matrix / TEST 13 expectations. If retaining an unscalable range for the co-partitioning pass is desirable, it should be gated on the existing preserve_file_partitions threshold rather than unconditional, and the reference selection must never let a preserved range beat a candidate that already provides target_partitions.

@stuhood

stuhood commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

If retaining an unscalable range for the co-partitioning pass is desirable, it should be gated on the existing preserve_file_partitions threshold rather than unconditional, and the reference selection must never let a preserved range beat a candidate that already provides target_partitions.

Agreed.

Thanks!

stuhood added a commit to paradedb/datafusion-distributed that referenced this pull request Sep 15, 2026

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

flushing comments


/// Why a [`RangePartitioning`] cannot be scaled to a requested partition count.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangePartitioningScaleError {

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 dont think eposing a public error type is the best path here. Most likely could be a datafusion internal error and have some private enum if needed

/// - `ordering` defines the partitioning key and ordering.
/// - `split_points` define the boundaries between adjacent partitions.
/// - `samples` define the maximum-resolution boundaries.
/// - `partition_count` selects how many ranges to derive from those samples.

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.

isnt split points still in the struct?

/// - `ordering` defines the partitioning key and ordering.
/// - `split_points` define the boundaries between adjacent partitions.
/// - `samples` define the maximum-resolution boundaries.
/// - `partition_count` selects how many ranges to derive from those samples.

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 also am not seeing partition_count but may be missing

/// samples provides capacity for up to `4 * partition_count + 1` partitions.
/// Choose the sampling factor for the workload; small inputs may not have
/// enough distinct values, and callers must handle insufficient capacity.
pub fn try_new_with_samples(

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 would love to see a concrete example of samles and split points relationship being shown. Like what an actual scaling would look like and the contract it enforces, like not exceeding the max samples.

This also makes me think, samples is not the most telling name for a public facing property. Maybe something like max_partition_bounds?

Comment on lines +251 to +261
/// Creates sample-backed range partitioning and validates the sample shape,
/// ordering, and target partition count.
///
/// `partition_count` must be at least one and no larger than
/// `samples.len() + 1`. When it is smaller than that maximum, the samples
/// are evenly down-sampled to derive the effective split points.
pub fn try_new_with_samples(
ordering: LexOrdering,
samples: Vec<SplitPoint>,
partition_count: usize,
) -> Result<Self> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't know if deprecating is needed rather than it jsut being an invariant. It seems we can alsways derive the samples form split points.

@stuhood do you think there is a use case for having a contructor that does not return a Result? I could see it if a use case vlaidated themselves and is really trying to squeeze perf

# Co-partitioning satisfaction does not prevent a repartition that increases
# parallelism. With target_partitions larger than the Range partition count,
# both sides are hash repartitioned.
# TEST 13: Compatible Range Join Preserves Unscalable Partitioning

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is this the inteded behavior we want though? What if we would benefit more from a repartition to 5 for more parallelism. We should make this configurable to the user in some way. Maybe a threshold of some type

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.

No, this is not intended. See #24766 (comment)

}
Err(RangePartitioningScaleError::InsufficientSamples { .. }) => {
preserved_unscalable_range = true;
None

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 returns None which I have tracked as meaning “keep the current range partitioning” and this happens even when preserve_file_partitions is disabled so I think this shouldnt happen

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.

so like a 3 partiitoned range merging with a 8 partitioned hash, I would think if the user doesn't opt in we should decide to use more parallelism

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.

On InsufficientSamples the Range input keeps its partition count even with preserve_file_partitions = 0, and even for single-input requirements (e.g. an aggregate) where nothing needs to line up. Range sources today are built with try_new (max == current count), so all existing Range users lose parallelism. range_satisfaction_config_matrix (NOT_MET, DISABLED, GREATER) flips HashReuse, and sqllogictest TEST 13 now joins at 4 partitions with target_partitions = 5. Please keep the layout only when the inputs are co-partitioned (and confirm on #24712 whether joins should also require preserve_file_partitions):

-                                    Err(RangePartitioningScaleError::InsufficientSamples { .. }) => {
+                                    Err(RangePartitioningScaleError::InsufficientSamples { .. })
+                                        if input_distributions.is_co_partitioned() =>
+                                    {
                                         preserved_unscalable_range = true;
                                         None
                                     }
+                                    Err(RangePartitioningScaleError::InsufficientSamples { .. }) => Some(
+                                        requirement
+                                            .clone()
+                                            .create_partitioning(target_partitions),
+                                    ),
                                     Err(error) => return Err(error.into()),

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.

are we intentionally rejecting here?

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.

shouldn't we attempt to scale

----
physical_plan
01)HashJoinExec: mode=Partitioned, join_type=Inner, on=[(range_key@0, range_key@0)], projection=[range_key@0, value@1, value@3]
02)--RepartitionExec: partitioning=Hash([range_key@0], 5), input_partitions=4

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 am very confused here, we are showing that we are not repartitioning but we are not doing this due to scaling. This was intentional to show that if we can increase parallelism in the system we should. I think a better test here would be showing increasing parallelism by repartitioning on range and scaling the points correctly

if should_add_hash_repartition {
// Enforce unmet requirements, or increase parallelism when beneficial.
if should_add_repartition {
let partitioning = match child.plan.output_partitioning() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think using an option here too is pretty confusing. Could we make it more explicit what the variants are like Keep or Repartition on Range

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

A small suggestion, other LGTM

}
Err(RangePartitioningScaleError::InsufficientSamples { .. }) => {
preserved_unscalable_range = true;
None

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.

On InsufficientSamples the Range input keeps its partition count even with preserve_file_partitions = 0, and even for single-input requirements (e.g. an aggregate) where nothing needs to line up. Range sources today are built with try_new (max == current count), so all existing Range users lose parallelism. range_satisfaction_config_matrix (NOT_MET, DISABLED, GREATER) flips HashReuse, and sqllogictest TEST 13 now joins at 4 partitions with target_partitions = 5. Please keep the layout only when the inputs are co-partitioned (and confirm on #24712 whether joins should also require preserve_file_partitions):

-                                    Err(RangePartitioningScaleError::InsufficientSamples { .. }) => {
+                                    Err(RangePartitioningScaleError::InsufficientSamples { .. })
+                                        if input_distributions.is_co_partitioned() =>
+                                    {
                                         preserved_unscalable_range = true;
                                         None
                                     }
+                                    Err(RangePartitioningScaleError::InsufficientSamples { .. }) => Some(
+                                        requirement
+                                            .clone()
+                                            .create_partitioning(target_partitions),
+                                    ),
                                     Err(error) => return Err(error.into()),

@stuhood

stuhood commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Thanks again for working on this @goutamadwant!

Some more breadcrumbs here: I've integrated this in datafusion-contrib/datafusion-distributed#730 and paradedb/paradedb#6342, and did not find any problems with the API other than those already mentioned here.

Let me know if we can help get this landed.

…g-review

# Conflicts:
#	datafusion/physical-expr/src/partitioning.rs
Signed-off-by: goutamadwant <workwithgoutam@gmail.com>
@github-actions github-actions Bot removed the sqllogictest SQL Logic Tests (.slt) label Sep 19, 2026
Signed-off-by: goutamadwant <workwithgoutam@gmail.com>
Signed-off-by: goutamadwant <workwithgoutam@gmail.com>
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 documentation Improvements or additions to documentation ffi Changes to the ffi crate optimizer Optimizer rules 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.

Allow for scaling RangePartitioning

5 participants