blob: 998d09285cf1d08887aca6cb738cf01cef36569a [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Logic for managing groups of [`PartitionedFile`]s in DataFusion
use crate::{FileRange, PartitionedFile};
use datafusion_common::Statistics;
use itertools::Itertools;
use std::cmp::{min, Ordering};
use std::collections::BinaryHeap;
use std::iter::repeat_with;
use std::mem;
use std::ops::{Deref, DerefMut, Index, IndexMut};
use std::sync::Arc;
/// Repartition input files into `target_partitions` partitions, if total file size exceed
/// `repartition_file_min_size`
///
/// This partitions evenly by file byte range, and does not have any knowledge
/// of how data is laid out in specific files. The specific `FileOpener` are
/// responsible for the actual partitioning on specific data source type. (e.g.
/// the `CsvOpener` will read lines overlap with byte range as well as
/// handle boundaries to ensure all lines will be read exactly once)
///
/// # Example
///
/// For example, if there are two files `A` and `B` that we wish to read with 4
/// partitions (with 4 threads) they will be divided as follows:
///
/// ```text
/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// ┌─────────────────┐
/// │ │ │ │
/// │ File A │
/// │ │ Range: 0-2MB │ │
/// │ │
/// │ └─────────────────┘ │
/// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
/// ┌─────────────────┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// │ │ ┌─────────────────┐
/// │ │ │ │ │ │
/// │ │ │ File A │
/// │ │ │ │ Range 2-4MB │ │
/// │ │ │ │
/// │ │ │ └─────────────────┘ │
/// │ File A (7MB) │ ────────▶ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
/// │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// │ │ ┌─────────────────┐
/// │ │ │ │ │ │
/// │ │ │ File A │
/// │ │ │ │ Range: 4-6MB │ │
/// │ │ │ │
/// │ │ │ └─────────────────┘ │
/// └─────────────────┘ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
/// ┌─────────────────┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// │ File B (1MB) │ ┌─────────────────┐
/// │ │ │ │ File A │ │
/// └─────────────────┘ │ Range: 6-7MB │
/// │ └─────────────────┘ │
/// ┌─────────────────┐
/// │ │ File B (1MB) │ │
/// │ │
/// │ └─────────────────┘ │
/// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
///
/// If target_partitions = 4,
/// divides into 4 groups
/// ```
///
/// # Maintaining Order
///
/// Within each group files are read sequentially. Thus, if the overall order of
/// tuples must be preserved, multiple files can not be mixed in the same group.
///
/// In this case, the code will split the largest files evenly into any
/// available empty groups, but the overall distribution may not be as even
/// as if the order did not need to be preserved.
///
/// ```text
/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// ┌─────────────────┐
/// │ │ │ │
/// │ File A │
/// │ │ Range: 0-2MB │ │
/// │ │
/// ┌─────────────────┐ │ └─────────────────┘ │
/// │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
/// │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// │ │ ┌─────────────────┐
/// │ │ │ │ │ │
/// │ │ │ File A │
/// │ │ │ │ Range 2-4MB │ │
/// │ File A (6MB) │ ────────▶ │ │
/// │ (ordered) │ │ └─────────────────┘ │
/// │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
/// │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// │ │ ┌─────────────────┐
/// │ │ │ │ │ │
/// │ │ │ File A │
/// │ │ │ │ Range: 4-6MB │ │
/// └─────────────────┘ │ │
/// ┌─────────────────┐ │ └─────────────────┘ │
/// │ File B (1MB) │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
/// │ (ordered) │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
/// └─────────────────┘ ┌─────────────────┐
/// │ │ File B (1MB) │ │
/// │ │
/// │ └─────────────────┘ │
/// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
///
/// If target_partitions = 4,
/// divides into 4 groups
/// ```
#[derive(Debug, Clone, Copy)]
pub struct FileGroupPartitioner {
/// how many partitions should be created
target_partitions: usize,
/// the minimum size for a file to be repartitioned.
repartition_file_min_size: usize,
/// if the order when reading the files must be preserved
preserve_order_within_groups: bool,
}
impl Default for FileGroupPartitioner {
fn default() -> Self {
Self::new()
}
}
impl FileGroupPartitioner {
/// Creates a new [`FileGroupPartitioner`] with default values:
/// 1. `target_partitions = 1`
/// 2. `repartition_file_min_size = 10MB`
/// 3. `preserve_order_within_groups = false`
pub fn new() -> Self {
Self {
target_partitions: 1,
repartition_file_min_size: 10 * 1024 * 1024,
preserve_order_within_groups: false,
}
}
/// Set the target partitions
pub fn with_target_partitions(mut self, target_partitions: usize) -> Self {
self.target_partitions = target_partitions;
self
}
/// Set the minimum size at which to repartition a file
pub fn with_repartition_file_min_size(
mut self,
repartition_file_min_size: usize,
) -> Self {
self.repartition_file_min_size = repartition_file_min_size;
self
}
/// Set whether the order of tuples within a file must be preserved
pub fn with_preserve_order_within_groups(
mut self,
preserve_order_within_groups: bool,
) -> Self {
self.preserve_order_within_groups = preserve_order_within_groups;
self
}
/// Repartition input files according to the settings on this [`FileGroupPartitioner`].
///
/// If no repartitioning is needed or possible, return `None`.
pub fn repartition_file_groups(
&self,
file_groups: &[FileGroup],
) -> Option<Vec<FileGroup>> {
if file_groups.is_empty() {
return None;
}
// Perform redistribution only in case all files should be read from beginning to end
let has_ranges = file_groups
.iter()
.flat_map(FileGroup::iter)
.any(|f| f.range.is_some());
if has_ranges {
return None;
}
// special case when order must be preserved
if self.preserve_order_within_groups {
self.repartition_preserving_order(file_groups)
} else {
self.repartition_evenly_by_size(file_groups)
}
}
/// Evenly repartition files across partitions by size, ignoring any
/// existing grouping / ordering
fn repartition_evenly_by_size(
&self,
file_groups: &[FileGroup],
) -> Option<Vec<FileGroup>> {
let target_partitions = self.target_partitions;
let repartition_file_min_size = self.repartition_file_min_size;
let flattened_files = file_groups.iter().flat_map(FileGroup::iter).collect_vec();
let total_size = flattened_files
.iter()
.map(|f| f.object_meta.size as i64)
.sum::<i64>();
if total_size < (repartition_file_min_size as i64) || total_size == 0 {
return None;
}
let target_partition_size =
(total_size as u64).div_ceil(target_partitions as u64);
let current_partition_index: usize = 0;
let current_partition_size: u64 = 0;
// Partition byte range evenly for all `PartitionedFile`s
let repartitioned_files = flattened_files
.into_iter()
.scan(
(current_partition_index, current_partition_size),
|state, source_file| {
let mut produced_files = vec![];
let mut range_start = 0;
while range_start < source_file.object_meta.size {
let range_end = min(
range_start + (target_partition_size - state.1),
source_file.object_meta.size,
);
let mut produced_file = source_file.clone();
produced_file.range = Some(FileRange {
start: range_start as i64,
end: range_end as i64,
});
produced_files.push((state.0, produced_file));
if state.1 + (range_end - range_start) >= target_partition_size {
state.0 += 1;
state.1 = 0;
} else {
state.1 += range_end - range_start;
}
range_start = range_end;
}
Some(produced_files)
},
)
.flatten()
.chunk_by(|(partition_idx, _)| *partition_idx)
.into_iter()
.map(|(_, group)| FileGroup::new(group.map(|(_, vals)| vals).collect_vec()))
.collect_vec();
Some(repartitioned_files)
}
/// Redistribute file groups across size preserving order
fn repartition_preserving_order(
&self,
file_groups: &[FileGroup],
) -> Option<Vec<FileGroup>> {
// Can't repartition and preserve order if there are more groups
// than partitions
if file_groups.len() >= self.target_partitions {
return None;
}
let num_new_groups = self.target_partitions - file_groups.len();
// If there is only a single file
if file_groups.len() == 1 && file_groups[0].len() == 1 {
return self.repartition_evenly_by_size(file_groups);
}
// Find which files could be split (single file groups)
let mut heap: BinaryHeap<_> = file_groups
.iter()
.enumerate()
.filter_map(|(group_index, group)| {
// ignore groups that do not have exactly 1 file
if group.len() == 1 {
Some(ToRepartition {
source_index: group_index,
file_size: group[0].object_meta.size,
new_groups: vec![group_index],
})
} else {
None
}
})
.map(CompareByRangeSize)
.collect();
// No files can be redistributed
if heap.is_empty() {
return None;
}
// Add new empty groups to which we will redistribute ranges of existing files
// Add new empty groups to which we will redistribute ranges of existing files
let mut file_groups: Vec<_> = file_groups
.iter()
.cloned()
.chain(repeat_with(|| FileGroup::new(Vec::new())).take(num_new_groups))
.collect();
// Divide up empty groups
for (group_index, group) in file_groups.iter().enumerate() {
if !group.is_empty() {
continue;
}
// Pick the file that has the largest ranges to read so far
let mut largest_group = heap.pop().unwrap();
largest_group.new_groups.push(group_index);
heap.push(largest_group);
}
// Distribute files to their newly assigned groups
while let Some(to_repartition) = heap.pop() {
let range_size = to_repartition.range_size() as i64;
let ToRepartition {
source_index,
file_size,
new_groups,
} = to_repartition.into_inner();
assert_eq!(file_groups[source_index].len(), 1);
let original_file = file_groups[source_index].pop().unwrap();
let last_group = new_groups.len() - 1;
let mut range_start: i64 = 0;
let mut range_end: i64 = range_size;
for (i, group_index) in new_groups.into_iter().enumerate() {
let target_group = &mut file_groups[group_index];
assert!(target_group.is_empty());
// adjust last range to include the entire file
if i == last_group {
range_end = file_size as i64;
}
target_group
.push(original_file.clone().with_range(range_start, range_end));
range_start = range_end;
range_end += range_size;
}
}
Some(file_groups)
}
}
/// Represents a group of partitioned files that'll be processed by a single thread.
/// Maintains optional statistics across all files in the group.
#[derive(Debug, Clone)]
pub struct FileGroup {
/// The files in this group
files: Vec<PartitionedFile>,
/// Optional statistics for the data across all files in the group
statistics: Option<Arc<Statistics>>,
}
impl FileGroup {
/// Creates a new FileGroup from a vector of PartitionedFile objects
pub fn new(files: Vec<PartitionedFile>) -> Self {
Self {
files,
statistics: None,
}
}
/// Returns the number of files in this group
pub fn len(&self) -> usize {
self.files.len()
}
/// Set the statistics for this group
pub fn with_statistics(mut self, statistics: Arc<Statistics>) -> Self {
self.statistics = Some(statistics);
self
}
/// Returns a slice of the files in this group
pub fn files(&self) -> &[PartitionedFile] {
&self.files
}
pub fn iter(&self) -> impl Iterator<Item = &PartitionedFile> {
self.files.iter()
}
pub fn into_inner(self) -> Vec<PartitionedFile> {
self.files
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
/// Removes the last element from the files vector and returns it, or None if empty
pub fn pop(&mut self) -> Option<PartitionedFile> {
self.files.pop()
}
/// Adds a file to the group
pub fn push(&mut self, partitioned_file: PartitionedFile) {
self.files.push(partitioned_file);
}
/// Get the specific file statistics for the given index
/// If the index is None, return the `FileGroup` statistics
pub fn file_statistics(&self, index: Option<usize>) -> Option<&Statistics> {
if let Some(index) = index {
self.files.get(index).and_then(|f| f.statistics.as_deref())
} else {
self.statistics.as_deref()
}
}
/// Get the mutable reference to the statistics for this group
pub fn statistics_mut(&mut self) -> Option<&mut Statistics> {
self.statistics.as_mut().map(Arc::make_mut)
}
/// Partition the list of files into `n` groups
pub fn split_files(mut self, n: usize) -> Vec<FileGroup> {
if self.is_empty() {
return vec![];
}
// ObjectStore::list does not guarantee any consistent order and for some
// implementations such as LocalFileSystem, it may be inconsistent. Thus
// Sort files by path to ensure consistent plans when run more than once.
self.files.sort_by(|a, b| a.path().cmp(b.path()));
// effectively this is div with rounding up instead of truncating
let chunk_size = self.len().div_ceil(n);
let mut chunks = Vec::with_capacity(n);
let mut current_chunk = Vec::with_capacity(chunk_size);
for file in self.files.drain(..) {
current_chunk.push(file);
if current_chunk.len() == chunk_size {
let full_chunk = FileGroup::new(mem::replace(
&mut current_chunk,
Vec::with_capacity(chunk_size),
));
chunks.push(full_chunk);
}
}
if !current_chunk.is_empty() {
chunks.push(FileGroup::new(current_chunk))
}
chunks
}
}
impl Index<usize> for FileGroup {
type Output = PartitionedFile;
fn index(&self, index: usize) -> &Self::Output {
&self.files[index]
}
}
impl IndexMut<usize> for FileGroup {
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
&mut self.files[index]
}
}
impl FromIterator<PartitionedFile> for FileGroup {
fn from_iter<I: IntoIterator<Item = PartitionedFile>>(iter: I) -> Self {
let files = iter.into_iter().collect();
FileGroup::new(files)
}
}
impl From<Vec<PartitionedFile>> for FileGroup {
fn from(files: Vec<PartitionedFile>) -> Self {
FileGroup::new(files)
}
}
impl Default for FileGroup {
fn default() -> Self {
Self::new(Vec::new())
}
}
/// Tracks how a individual file will be repartitioned
#[derive(Debug, Clone)]
struct ToRepartition {
/// the index from which the original file will be taken
source_index: usize,
/// the size of the original file
file_size: u64,
/// indexes of which group(s) will this be distributed to (including `source_index`)
new_groups: Vec<usize>,
}
impl ToRepartition {
/// How big will each file range be when this file is read in its new groups?
fn range_size(&self) -> u64 {
self.file_size / (self.new_groups.len() as u64)
}
}
struct CompareByRangeSize(ToRepartition);
impl CompareByRangeSize {
fn into_inner(self) -> ToRepartition {
self.0
}
}
impl Ord for CompareByRangeSize {
fn cmp(&self, other: &Self) -> Ordering {
self.0.range_size().cmp(&other.0.range_size())
}
}
impl PartialOrd for CompareByRangeSize {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for CompareByRangeSize {
fn eq(&self, other: &Self) -> bool {
// PartialEq must be consistent with PartialOrd
self.cmp(other) == Ordering::Equal
}
}
impl Eq for CompareByRangeSize {}
impl Deref for CompareByRangeSize {
type Target = ToRepartition;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for CompareByRangeSize {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[cfg(test)]
mod test {
use super::*;
/// Empty file won't get partitioned
#[test]
fn repartition_empty_file_only() {
let partitioned_file_empty = pfile("empty", 0);
let file_group = vec![FileGroup::new(vec![partitioned_file_empty])];
let partitioned_files = FileGroupPartitioner::new()
.with_target_partitions(4)
.with_repartition_file_min_size(0)
.repartition_file_groups(&file_group);
assert_partitioned_files(None, partitioned_files);
}
/// Repartition when there is a empty file in file groups
#[test]
fn repartition_empty_files() {
let pfile_a = pfile("a", 10);
let pfile_b = pfile("b", 10);
let pfile_empty = pfile("empty", 0);
let empty_first = vec![
FileGroup::new(vec![pfile_empty.clone()]),
FileGroup::new(vec![pfile_a.clone()]),
FileGroup::new(vec![pfile_b.clone()]),
];
let empty_middle = vec![
FileGroup::new(vec![pfile_a.clone()]),
FileGroup::new(vec![pfile_empty.clone()]),
FileGroup::new(vec![pfile_b.clone()]),
];
let empty_last = vec![
FileGroup::new(vec![pfile_a]),
FileGroup::new(vec![pfile_b]),
FileGroup::new(vec![pfile_empty]),
];
// Repartition file groups into x partitions
let expected_2 = vec![
FileGroup::new(vec![pfile("a", 10).with_range(0, 10)]),
FileGroup::new(vec![pfile("b", 10).with_range(0, 10)]),
];
let expected_3 = vec![
FileGroup::new(vec![pfile("a", 10).with_range(0, 7)]),
FileGroup::new(vec![
pfile("a", 10).with_range(7, 10),
pfile("b", 10).with_range(0, 4),
]),
FileGroup::new(vec![pfile("b", 10).with_range(4, 10)]),
];
let file_groups_tests = [empty_first, empty_middle, empty_last];
for fg in file_groups_tests {
let all_expected = [(2, expected_2.clone()), (3, expected_3.clone())];
for (n_partition, expected) in all_expected {
let actual = FileGroupPartitioner::new()
.with_target_partitions(n_partition)
.with_repartition_file_min_size(10)
.repartition_file_groups(&fg);
assert_partitioned_files(Some(expected), actual);
}
}
}
#[test]
fn repartition_single_file() {
// Single file, single partition into multiple partitions
let single_partition = vec![FileGroup::new(vec![pfile("a", 123)])];
let actual = FileGroupPartitioner::new()
.with_target_partitions(4)
.with_repartition_file_min_size(10)
.repartition_file_groups(&single_partition);
let expected = Some(vec![
FileGroup::new(vec![pfile("a", 123).with_range(0, 31)]),
FileGroup::new(vec![pfile("a", 123).with_range(31, 62)]),
FileGroup::new(vec![pfile("a", 123).with_range(62, 93)]),
FileGroup::new(vec![pfile("a", 123).with_range(93, 123)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_too_much_partitions() {
// Single file, single partition into 96 partitions
let partitioned_file = pfile("a", 8);
let single_partition = vec![FileGroup::new(vec![partitioned_file])];
let actual = FileGroupPartitioner::new()
.with_target_partitions(96)
.with_repartition_file_min_size(5)
.repartition_file_groups(&single_partition);
let expected = Some(vec![
FileGroup::new(vec![pfile("a", 8).with_range(0, 1)]),
FileGroup::new(vec![pfile("a", 8).with_range(1, 2)]),
FileGroup::new(vec![pfile("a", 8).with_range(2, 3)]),
FileGroup::new(vec![pfile("a", 8).with_range(3, 4)]),
FileGroup::new(vec![pfile("a", 8).with_range(4, 5)]),
FileGroup::new(vec![pfile("a", 8).with_range(5, 6)]),
FileGroup::new(vec![pfile("a", 8).with_range(6, 7)]),
FileGroup::new(vec![pfile("a", 8).with_range(7, 8)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_multiple_partitions() {
// Multiple files in single partition after redistribution
let source_partitions = vec![
FileGroup::new(vec![pfile("a", 40)]),
FileGroup::new(vec![pfile("b", 60)]),
];
let actual = FileGroupPartitioner::new()
.with_target_partitions(3)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
let expected = Some(vec![
FileGroup::new(vec![pfile("a", 40).with_range(0, 34)]),
FileGroup::new(vec![
pfile("a", 40).with_range(34, 40),
pfile("b", 60).with_range(0, 28),
]),
FileGroup::new(vec![pfile("b", 60).with_range(28, 60)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_same_num_partitions() {
// "Rebalance" files across partitions
let source_partitions = vec![
FileGroup::new(vec![pfile("a", 40)]),
FileGroup::new(vec![pfile("b", 60)]),
];
let actual = FileGroupPartitioner::new()
.with_target_partitions(2)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
let expected = Some(vec![
FileGroup::new(vec![
pfile("a", 40).with_range(0, 40),
pfile("b", 60).with_range(0, 10),
]),
FileGroup::new(vec![pfile("b", 60).with_range(10, 60)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_no_action_ranges() {
// No action due to Some(range) in second file
let source_partitions = vec![
FileGroup::new(vec![pfile("a", 123)]),
FileGroup::new(vec![pfile("b", 144).with_range(1, 50)]),
];
let actual = FileGroupPartitioner::new()
.with_target_partitions(65)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
assert_partitioned_files(None, actual)
}
#[test]
fn repartition_no_action_min_size() {
// No action due to target_partition_size
let single_partition = vec![FileGroup::new(vec![pfile("a", 123)])];
let actual = FileGroupPartitioner::new()
.with_target_partitions(65)
.with_repartition_file_min_size(500)
.repartition_file_groups(&single_partition);
assert_partitioned_files(None, actual)
}
#[test]
fn repartition_no_action_zero_files() {
// No action due to no files
let empty_partition = vec![];
let partitioner = FileGroupPartitioner::new()
.with_target_partitions(65)
.with_repartition_file_min_size(500);
assert_partitioned_files(None, repartition_test(partitioner, empty_partition))
}
#[test]
fn repartition_ordered_no_action_too_few_partitions() {
// No action as there are no new groups to redistribute to
let input_partitions = vec![
FileGroup::new(vec![pfile("a", 100)]),
FileGroup::new(vec![pfile("b", 200)]),
];
let actual = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_target_partitions(2)
.with_repartition_file_min_size(10)
.repartition_file_groups(&input_partitions);
assert_partitioned_files(None, actual)
}
#[test]
fn repartition_ordered_no_action_file_too_small() {
// No action as there are no new groups to redistribute to
let single_partition = vec![FileGroup::new(vec![pfile("a", 100)])];
let actual = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_target_partitions(2)
// file is too small to repartition
.with_repartition_file_min_size(1000)
.repartition_file_groups(&single_partition);
assert_partitioned_files(None, actual)
}
#[test]
fn repartition_ordered_one_large_file() {
// "Rebalance" the single large file across partitions
let source_partitions = vec![FileGroup::new(vec![pfile("a", 100)])];
let actual = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_target_partitions(3)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
let expected = Some(vec![
FileGroup::new(vec![pfile("a", 100).with_range(0, 34)]),
FileGroup::new(vec![pfile("a", 100).with_range(34, 68)]),
FileGroup::new(vec![pfile("a", 100).with_range(68, 100)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_ordered_one_large_one_small_file() {
// "Rebalance" the single large file across empty partitions, but can't split
// small file
let source_partitions = vec![
FileGroup::new(vec![pfile("a", 100)]),
FileGroup::new(vec![pfile("b", 30)]),
];
let actual = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_target_partitions(4)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
let expected = Some(vec![
// scan first third of "a"
FileGroup::new(vec![pfile("a", 100).with_range(0, 33)]),
// only b in this group (can't do this)
FileGroup::new(vec![pfile("b", 30).with_range(0, 30)]),
// second third of "a"
FileGroup::new(vec![pfile("a", 100).with_range(33, 66)]),
// final third of "a"
FileGroup::new(vec![pfile("a", 100).with_range(66, 100)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_ordered_two_large_files() {
// "Rebalance" two large files across empty partitions, but can't mix them
let source_partitions = vec![
FileGroup::new(vec![pfile("a", 100)]),
FileGroup::new(vec![pfile("b", 100)]),
];
let actual = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_target_partitions(4)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
let expected = Some(vec![
// scan first half of "a"
FileGroup::new(vec![pfile("a", 100).with_range(0, 50)]),
// scan first half of "b"
FileGroup::new(vec![pfile("b", 100).with_range(0, 50)]),
// second half of "a"
FileGroup::new(vec![pfile("a", 100).with_range(50, 100)]),
// second half of "b"
FileGroup::new(vec![pfile("b", 100).with_range(50, 100)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_ordered_two_large_one_small_files() {
// "Rebalance" two large files and one small file across empty partitions
let source_partitions = vec![
FileGroup::new(vec![pfile("a", 100)]),
FileGroup::new(vec![pfile("b", 100)]),
FileGroup::new(vec![pfile("c", 30)]),
];
let partitioner = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_repartition_file_min_size(10);
// with 4 partitions, can only split the first large file "a"
let actual = partitioner
.with_target_partitions(4)
.repartition_file_groups(&source_partitions);
let expected = Some(vec![
// scan first half of "a"
FileGroup::new(vec![pfile("a", 100).with_range(0, 50)]),
// All of "b"
FileGroup::new(vec![pfile("b", 100).with_range(0, 100)]),
// All of "c"
FileGroup::new(vec![pfile("c", 30).with_range(0, 30)]),
// second half of "a"
FileGroup::new(vec![pfile("a", 100).with_range(50, 100)]),
]);
assert_partitioned_files(expected, actual);
// With 5 partitions, we can split both "a" and "b", but they can't be intermixed
let actual = partitioner
.with_target_partitions(5)
.repartition_file_groups(&source_partitions);
let expected = Some(vec![
// scan first half of "a"
FileGroup::new(vec![pfile("a", 100).with_range(0, 50)]),
// scan first half of "b"
FileGroup::new(vec![pfile("b", 100).with_range(0, 50)]),
// All of "c"
FileGroup::new(vec![pfile("c", 30).with_range(0, 30)]),
// second half of "a"
FileGroup::new(vec![pfile("a", 100).with_range(50, 100)]),
// second half of "b"
FileGroup::new(vec![pfile("b", 100).with_range(50, 100)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_ordered_one_large_one_small_existing_empty() {
// "Rebalance" files using existing empty partition
let source_partitions = vec![
FileGroup::new(vec![pfile("a", 100)]),
FileGroup::default(),
FileGroup::new(vec![pfile("b", 40)]),
FileGroup::default(),
];
let actual = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_target_partitions(5)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
// Of the three available groups (2 original empty and 1 new from the
// target partitions), assign two to "a" and one to "b"
let expected = Some(vec![
// Scan of "a" across three groups
FileGroup::new(vec![pfile("a", 100).with_range(0, 33)]),
FileGroup::new(vec![pfile("a", 100).with_range(33, 66)]),
// scan first half of "b"
FileGroup::new(vec![pfile("b", 40).with_range(0, 20)]),
// final third of "a"
FileGroup::new(vec![pfile("a", 100).with_range(66, 100)]),
// second half of "b"
FileGroup::new(vec![pfile("b", 40).with_range(20, 40)]),
]);
assert_partitioned_files(expected, actual);
}
#[test]
fn repartition_ordered_existing_group_multiple_files() {
// groups with multiple files in a group can not be changed, but can divide others
let source_partitions = vec![
// two files in an existing partition
FileGroup::new(vec![pfile("a", 100), pfile("b", 100)]),
FileGroup::new(vec![pfile("c", 40)]),
];
let actual = FileGroupPartitioner::new()
.with_preserve_order_within_groups(true)
.with_target_partitions(3)
.with_repartition_file_min_size(10)
.repartition_file_groups(&source_partitions);
// Of the three available groups (2 original empty and 1 new from the
// target partitions), assign two to "a" and one to "b"
let expected = Some(vec![
// don't try and rearrange files in the existing partition
// assuming that the caller had a good reason to put them that way.
// (it is technically possible to split off ranges from the files if desired)
FileGroup::new(vec![pfile("a", 100), pfile("b", 100)]),
// first half of "c"
FileGroup::new(vec![pfile("c", 40).with_range(0, 20)]),
// second half of "c"
FileGroup::new(vec![pfile("c", 40).with_range(20, 40)]),
]);
assert_partitioned_files(expected, actual);
}
/// Asserts that the two groups of [`PartitionedFile`] are the same
/// (PartitionedFile doesn't implement PartialEq)
fn assert_partitioned_files(
expected: Option<Vec<FileGroup>>,
actual: Option<Vec<FileGroup>>,
) {
match (expected, actual) {
(None, None) => {}
(Some(_), None) => panic!("Expected Some, got None"),
(None, Some(_)) => panic!("Expected None, got Some"),
(Some(expected), Some(actual)) => {
let expected_string = format!("{expected:#?}");
let actual_string = format!("{actual:#?}");
assert_eq!(expected_string, actual_string);
}
}
}
/// returns a partitioned file with the specified path and size
fn pfile(path: impl Into<String>, file_size: u64) -> PartitionedFile {
PartitionedFile::new(path, file_size)
}
/// repartition the file groups both with and without preserving order
/// asserting they return the same value and returns that value
fn repartition_test(
partitioner: FileGroupPartitioner,
file_groups: Vec<FileGroup>,
) -> Option<Vec<FileGroup>> {
let repartitioned = partitioner.repartition_file_groups(&file_groups);
let repartitioned_preserving_sort = partitioner
.with_preserve_order_within_groups(true)
.repartition_file_groups(&file_groups);
assert_partitioned_files(repartitioned.clone(), repartitioned_preserving_sort);
repartitioned
}
}