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
160 changes: 160 additions & 0 deletions datafusion/datasource-parquet/src/access_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,10 +573,83 @@ impl ParquetAccessPlan {
let row_group_indexes = self.row_group_indexes();
let row_selection = self.into_overall_row_selection(row_group_meta_data)?;

let (row_group_indexes, row_selection) =
strip_empty_row_groups(row_group_indexes, row_selection, row_group_meta_data);

PreparedAccessPlan::new(row_group_indexes, row_selection)
}
}

/// Drop row groups whose post-pruning `RowSelection` selects zero rows, so the
/// prepared plan stays well-formed: `with_row_groups(...)` never names a row
/// group the decoder would immediately skip.
///
/// arrow-rs's push decoder silently advances past an all-skipped row group
/// inside `try_next_reader` without handing back a reader, so keeping such a
/// row group in the plan would leave its row-group list one entry longer than
/// the readers the decoder produces. An empty selection is reachable today via
/// [`ParquetAccessPlan::scan_selection`], which intersects an existing
/// `Selection` with a new one and can leave a row group selecting nothing.
/// A well-formed plan here is also a precondition for re-enabling runtime
/// pruning under a live selection (#24358) and for #23696.
///
/// Walks the flat `RowSelection` once with [`OverallRowSelectionCursor`],
/// rather than a per-row-group [`RowSelection::split_off`] (which reallocates
/// the selector tail on every call, i.e. O(row groups × selectors)), keeping
/// only the row groups that still select at least one row. `row_group_meta_data`
/// is the **full file** metadata, indexed absolutely by row-group index, while
/// `row_selection` covers only the scanned row groups — matching
/// `into_overall_row_selection`, which emits nothing for a skipped row group.
/// When `row_selection` is `None` no row group can be empty and the inputs are
/// returned unchanged.
fn strip_empty_row_groups(
row_group_indexes: Vec<usize>,
row_selection: Option<RowSelection>,
row_group_meta_data: &[RowGroupMetaData],
) -> (Vec<usize>, Option<RowSelection>) {
let Some(selection) = row_selection else {
return (row_group_indexes, None);
};

let mut cursor = OverallRowSelectionCursor::new(selection);
let mut kept_indexes = Vec::with_capacity(row_group_indexes.len());
let mut kept_selectors: Vec<RowSelector> = Vec::new();

for &rg_idx in row_group_indexes.iter() {
let rg_row_count = row_group_meta_data[rg_idx].num_rows() as 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.

Worth a note here: this indexes row_group_meta_data absolutely, while remaining only covers the row groups that are actually scanned. That pairing is correct — into_overall_row_selection emits nothing for RowGroupAccess::Skip — but it's the subtlest thing in the function, and both new tests pass vec![0, 1, 2, 3], so nothing pins it down.

A case with a non-contiguous list (e.g. vec![1, 3], RGs 0 and 2 skipped) would lock in exactly this. A one-line debug_assert! on the precondition, or a sentence saying the slice is the full file metadata, would also help — note that into_overall_row_selection uses zip in the same situation, so a short slice truncates silently there but panics here.


Generated by Claude Code

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.

Thanks — added test_strip_empty_row_groups_non_contiguous_indexes (vec![1, 3], RGs 0/2 skipped) which pins the absolute indexing, and the doc now states row_group_meta_data is the full-file metadata indexed absolutely while row_selection covers only the scanned groups.

// Pull this row group's fragments off the shared cursor in a single
// pass (no `split_off` tail reallocation).
let start = kept_selectors.len();
let mut selected = 0usize;
let mut taken = 0usize;
while taken < rg_row_count {
let Some(fragment) = cursor.take(rg_row_count - taken) else {
break;
};
taken += fragment.row_count;
if !fragment.skip {
selected += fragment.row_count;
}
kept_selectors.push(fragment);
}
if selected > 0 {
kept_indexes.push(rg_idx);
} else {
// Empty row group: arrow-rs would silently skip it. Drop it and its
// fragments so the plan stays 1:1 with the readers.
kept_selectors.truncate(start);
}
}

let result_selection = if kept_selectors.is_empty() {
None
} else {
Some(RowSelection::from(kept_selectors))
};

(kept_indexes, result_selection)
}

/// Represents a prepared, fully resolved [`ParquetAccessPlan`]
///
/// The [`RowSelection`] represents the result of applying all pruning such as
Expand Down Expand Up @@ -1430,4 +1503,91 @@ mod test {
// Ordered by min(a) ASC only: 3, 4, 5.
assert_eq!(result.row_group_indexes, vec![1, 2, 0]);
}

#[test]
fn test_strip_empty_row_groups_drops_only_empties() {
// 4 row groups of [10, 20, 30, 40] rows. RG 1 and RG 3 select nothing
// after pruning, so they must be dropped; RG 0 and RG 2 survive with
// their re-concatenated selections intact.
let selection = RowSelection::from(vec![
RowSelector::select(10), // RG 0: keep all 10
RowSelector::skip(30), // RG 1 (20) fully skipped + RG 2's leading 10
RowSelector::select(20), // RG 2: keep 20
RowSelector::skip(40), // RG 3: skip all 40
]);

let (indexes, result) = strip_empty_row_groups(
vec![0, 1, 2, 3],
Some(selection),
&ROW_GROUP_METADATA,
);

// RG 1 and RG 3 are dropped; the surviving indexes stay in order.
assert_eq!(indexes, vec![0, 2]);
let result = result.expect("survivors keep a selection");
assert_eq!(result.row_count(), 30); // 10 from RG 0 + 20 from RG 2
assert_eq!(result.skipped_row_count(), 10); // RG 2's leading skip
}

#[test]
fn test_strip_empty_row_groups_non_contiguous_indexes() {
// Only RG 1 (20 rows) and RG 3 (40 rows) are scanned — RG 0 and RG 2
// are whole-group skips, so the selection covers 20 + 40 = 60 rows.
// This pins down that `strip` indexes `row_group_meta_data`
// *absolutely* (meta[1]=20, meta[3]=40): a relative reading would take
// 10 then 20 rows and misalign the split.
let selection = RowSelection::from(vec![
RowSelector::skip(20), // RG 1: skip all 20 -> dropped
RowSelector::select(40), // RG 3: keep all 40
]);

let (indexes, result) =
strip_empty_row_groups(vec![1, 3], Some(selection), &ROW_GROUP_METADATA);

assert_eq!(indexes, vec![3]);
let result = result.expect("RG 3 survives");
assert_eq!(result.row_count(), 40);
assert_eq!(result.skipped_row_count(), 0);
}

#[test]
fn test_strip_empty_row_groups_none_selection_unchanged() {
// With no row selection no row group can be empty, so the inputs pass
// through unchanged.
let (indexes, result) =
strip_empty_row_groups(vec![0, 1, 2, 3], None, &ROW_GROUP_METADATA);
assert_eq!(indexes, vec![0, 1, 2, 3]);
assert!(result.is_none());
}

#[test]
fn test_prepare_strips_row_group_emptied_by_intersecting_selections() {
// The reachable producer: `scan_selection` intersects two disjoint
// selections within RG 1 to nothing, leaving an empty `Selection` in
// the plan (its own `rows_selected > 0` guard checks the incoming
// selection, not the intersection). `prepare` must strip that row
// group so the prepared plan never names a row group the decoder would
// silently skip.
let mut plan = ParquetAccessPlan::new_all(4); // RGs [10, 20, 30, 40]

// RG 1 (20 rows): select rows 0..10, then intersect with rows 10..20.
plan.scan_selection(
1,
RowSelection::from(vec![RowSelector::select(10), RowSelector::skip(10)]),
);
plan.scan_selection(
1,
RowSelection::from(vec![RowSelector::skip(10), RowSelector::select(10)]),
);
// RG 2 keeps a genuine partial selection so a flat selection is emitted.
plan.scan_selection(
2,
RowSelection::from(vec![RowSelector::skip(10), RowSelector::select(20)]),
);

let prepared = plan.prepare(&ROW_GROUP_METADATA).expect("prepare");

// RG 1 (emptied by the intersection) is stripped; RG 0/2/3 remain.
assert_eq!(prepared.row_group_indexes, vec![0, 2, 3]);
}
}
24 changes: 11 additions & 13 deletions datafusion/datasource-parquet/src/sort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ mod tests {

#[test]
fn test_prepared_access_plan_reverse_empty_selection() {
// Test: all rows are skipped
// Test: all rows are skipped. After `strip_empty_row_groups`, every row
// group's selection is empty, so the whole plan strips to nothing.
let metadata = create_test_metadata(vec![100, 100, 100]);

let mut access_plan = ParquetAccessPlan::new_all(3);
Expand All @@ -423,21 +424,18 @@ mod tests {
.prepare(rg_metadata)
.expect("Failed to create PreparedAccessPlan");

// All row groups are empty after pruning, so they are stripped and the
// prepared plan is empty (rather than carrying a selection that skips
// every row).
assert!(prepared_plan.row_group_indexes.is_empty());
assert!(prepared_plan.row_selection.is_none());

// Reversing an empty plan stays empty.
let reversed_plan = prepared_plan
.reverse(&metadata)
.expect("Failed to reverse PreparedAccessPlan");

// Should still skip all rows
let total_selected: usize = reversed_plan
.row_selection
.as_ref()
.unwrap()
.iter()
.filter(|s| !s.skip)
.map(|s| s.row_count)
.sum();

assert_eq!(total_selected, 0);
assert!(reversed_plan.row_group_indexes.is_empty());
assert!(reversed_plan.row_selection.is_none());
}

#[test]
Expand Down