Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4760,7 +4760,7 @@ fn parallelization_multiple_files() -> Result<()> {
@r"
SortRequiredExec: [a@0 ASC]
FilterExec: c@2 = 0
DataSourceExec: file_groups={8 groups: [[x:0..25], [y:0..25], [x:25..50], [y:25..50], [x:50..75], [y:50..75], [x:75..100], [y:75..100]]}, projection=[a, b, c, d, e], output_ordering=[a@0 ASC], file_type=parquet
DataSourceExec: file_groups={8 groups: [[x:0..25], [x:25..50], [x:50..75], [x:75..100], [y:0..25], [y:25..50], [y:50..75], [y:75..100]]}, projection=[a, b, c, d, e], output_ordering=[a@0 ASC], file_type=parquet
");
let plan_8_sort = test_config_concurrency_8.to_plan(plan, &SORT_DISTRIB_DISTRIB);
assert_plan!(plan_8_distrib, plan_8_sort);
Expand Down
187 changes: 187 additions & 0 deletions datafusion/datasource/src/file_scan_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,23 @@ impl DataSource for FileScanConfig {
return Ok(None);
}

// An ordered scan can use the size-balanced split of an unordered
// scan when every new group is still sorted. That split keeps the
// files in group order and only moves the group boundaries, so groups
// like `[f1, f2], [f3, f4]` with a large `f1` become
// `[f1 head], [f1 tail, f2], ...`.
if output_ordering.is_some()
&& let Some(balanced) = self.file_source.repartitioned(
target_partitions,
repartition_file_min_size,
None,
self,
)?
&& balanced.validated_output_ordering() == self.validated_output_ordering()

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 ordering check can accept an invalid merge when sort keys contain NULLs, because the min/max validation ignores null counts. For example, with ASC NULLS LAST, combining [1, NULL] and [2, 3] can place NULL before non-null values while still passing validation. Please add a null-aware check that falls back when ordering cannot be proven, including unknown null counts, without rejecting valid singleton groups. Also add regression tests for both NULLS FIRST and NULLS LAST, including a SQL test that reads rows from both files and verifies the results.

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.

This is already reachable on main without this PR: with [1, NULL] and [2, 3] in two files declared WITH ORDER (a ASC NULLS LAST) and target_partitions = 1, both files are read in one partition, the sort is removed, and the query returns 1, NULL, 2, 3. #26280 (for #26278) fixes it in MinMaxStatistics, which is the check this PR relies on: it puts NULLs into the file bounds when a file may contain them, treats unknown null counts as possibly NULL, and leaves single-file groups alone. So I'd rather build on that than add a second check here.

I checked this PR on top of #26280. Once it merges I'll rebase and add a unit test where the balanced split would put a file with NULLs after another file under NULLS FIRST (it is rejected and the ordered split is used), plus sqllogictest cases with two files for NULLS FIRST and NULLS LAST that read the rows back, with the NULLs at the allowed end (ordering kept) and at the wrong end (sort added).

{
return Ok(Some(Arc::new(balanced)));
}

let source = self.file_source.repartitioned(
target_partitions,
repartition_file_min_size,
Expand Down Expand Up @@ -3361,6 +3378,176 @@ mod tests {
))
}

fn ordered_scan_with_groups(file_groups: Vec<FileGroup>) -> FileScanConfig {
let schema =
Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, true)]));
let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
FileScanConfigBuilder::new(
ObjectStoreUrl::local_filesystem(),
Arc::new(MockSource::new(schema)),
)
.with_file_groups(file_groups)
.with_output_ordering(vec![LexOrdering::new(vec![sort_expr]).unwrap()])
.build()
}

fn sized_file_with_stats(
name: &str,
size: u64,
min: f64,
max: f64,
) -> PartitionedFile {
let mut file = make_file_with_stats(name, min, max);
file.object_meta.size = size;
file
}

/// The files of each group, with whether each file reads only a range
fn group_files(config: &FileScanConfig) -> Vec<Vec<(&str, bool)>> {
config
.file_groups
.iter()
.map(|group| {
group
.iter()
.map(|f| (f.object_meta.location.as_ref(), f.range.is_some()))
.collect()
})
.collect()
}

fn group_sizes(config: &FileScanConfig) -> Vec<u64> {
config
.file_groups
.iter()
.map(|group| group.iter().map(|f| f.effective_size()).sum())
.collect()
}

/// An ordered scan whose files are sorted across groups gets the same
/// size-balanced split as an unordered scan and keeps its ordering.
#[test]
fn repartition_balances_ordered_groups() -> Result<()> {
let config = ordered_scan_with_groups(vec![
FileGroup::new(vec![
sized_file_with_stats("f1", 8000, 0.0, 9.0),
sized_file_with_stats("f2", 8000, 10.0, 19.0),
]),
FileGroup::new(vec![
sized_file_with_stats("f3", 1000, 20.0, 29.0),
sized_file_with_stats("f4", 1000, 30.0, 39.0),
]),
]);
let ordering = config.output_ordering.first().cloned();
assert_eq!(group_sizes(&config), vec![16000, 2000]);

let repartitioned = DataSource::repartitioned(&config, 4, 0, ordering)?
.expect("ordered groups should be rebalanced");
let repartitioned = repartitioned
.downcast_ref::<FileScanConfig>()
.expect("expected FileScanConfig");
assert_eq!(group_sizes(repartitioned), vec![4500, 4500, 4500, 4500]);
assert_eq!(
repartitioned.validated_output_ordering(),
config.validated_output_ordering()
);
Ok(())
}

/// If moving the group boundaries would put overlapping files in one
/// group, the scan keeps its groups and its ordering.
#[test]
fn repartition_keeps_ordered_groups_when_rebalancing_breaks_order() -> Result<()> {
let config = ordered_scan_with_groups(vec![
FileGroup::new(vec![
sized_file_with_stats("f1", 8000, 0.0, 9.0),
sized_file_with_stats("f2", 8000, 10.0, 19.0),
]),
FileGroup::new(vec![
sized_file_with_stats("f3", 1000, 5.0, 15.0),
sized_file_with_stats("f4", 1000, 16.0, 25.0),
]),
]);
let ordering = config.output_ordering.first().cloned();
assert_eq!(config.validated_output_ordering().len(), 1);

assert!(DataSource::repartitioned(&config, 4, 0, ordering)?.is_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.

The current rejection test only covers a case where both repartitioning strategies return None, so it doesn't verify that the ordered fallback can succeed. Could you add a mixed-group test using [[f1(8000, 0..9), f2(8000, 10..19)], [f3(1000, 5..15)]] with four target partitions, and verify that the balanced candidate is rejected while the ordered fallback returns Some with the first group intact, f3 split, and ordering preserved?

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.

Added repartition_falls_back_to_ordered_split_when_rebalancing_breaks_order with this layout and 4 target partitions. The balanced split is rejected, and the ordered split keeps [f1, f2] as the first group, splits f3 into the other three groups, and keeps the ordering.

Ok(())
}

/// With `NULLS FIRST`, a file with NULLs can only be the first file of a
/// group, so a size-balanced split that puts it after another file is not
/// used.
#[test]
fn repartition_keeps_ordered_groups_when_rebalancing_misplaces_nulls() -> Result<()> {
let mut with_nulls = sized_file_with_stats("f2", 1000, 10.0, 19.0);
Arc::make_mut(with_nulls.statistics.as_mut().unwrap()).column_statistics[0]
.null_count = Precision::Exact(1);
let config = ordered_scan_with_groups(vec![
FileGroup::new(vec![sized_file_with_stats("f1", 8000, 0.0, 9.0)]),
FileGroup::new(vec![with_nulls]),
]);
let ordering = config.output_ordering.first().cloned();
assert_eq!(config.validated_output_ordering().len(), 1);

let repartitioned = DataSource::repartitioned(&config, 4, 0, ordering)?
.expect("the large file should be split");
let repartitioned = repartitioned
.downcast_ref::<FileScanConfig>()
.expect("expected FileScanConfig");
assert_eq!(
group_files(repartitioned),
vec![
vec![("f1", true)],
vec![("f2", true)],
vec![("f1", true)],
vec![("f1", true)],
]
);
assert_eq!(
repartitioned.validated_output_ordering(),
config.validated_output_ordering()
);
Ok(())
}

/// If moving the group boundaries would put overlapping files in one
/// group, the ordered split is used instead: it keeps multi-file groups
/// and only splits single-file groups.
#[test]
fn repartition_falls_back_to_ordered_split_when_rebalancing_breaks_order()
-> Result<()> {
let config = ordered_scan_with_groups(vec![
FileGroup::new(vec![
sized_file_with_stats("f1", 8000, 0.0, 9.0),
sized_file_with_stats("f2", 8000, 10.0, 19.0),
]),
FileGroup::new(vec![sized_file_with_stats("f3", 1000, 5.0, 15.0)]),
]);
let ordering = config.output_ordering.first().cloned();
assert_eq!(config.validated_output_ordering().len(), 1);

let repartitioned = DataSource::repartitioned(&config, 4, 0, ordering)?
.expect("the single-file group should be split");
let repartitioned = repartitioned
.downcast_ref::<FileScanConfig>()
.expect("expected FileScanConfig");
assert_eq!(
group_files(repartitioned),
vec![
vec![("f1", false), ("f2", false)],
vec![("f3", true)],
vec![("f3", true)],
vec![("f3", true)],
]
);
assert_eq!(
repartitioned.validated_output_ordering(),
config.validated_output_ordering()
);
Ok(())
}

fn make_exact_empty_file(name: &str) -> PartitionedFile {
PartitionedFile::new(name.to_string(), 1024).with_statistics(Arc::new(
Statistics {
Expand Down
Loading
Loading