This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 7fefcec702 feat: Add row-group-local RowSelection support to the push 
decoder (#10702)
7fefcec702 is described below

commit 7fefcec702bab62ce33596ad6c80f5854a89703b
Author: Huaijin <[email protected]>
AuthorDate: Tue Aug 25 05:51:46 2026 +0800

    feat: Add row-group-local RowSelection support to the push decoder (#10702)
    
    # Which issue does this PR close?
    
    - Closes #10624.
    
    # Rationale for this change
    
    DataFusion makes row-group-local selection decisions
    (`ParquetAccessPlan`), but the reader APIs only accept selected row
    groups plus a single global `RowSelection`. Callers must concatenate
    per-row-group selections into one global selection, which arrow-rs then
    re-partitions back into per-row-group selections during decoding. This
    round trip is wasted work and loses each selection's representation
    (bitmap vs. selector).
    
    # What changes are included in this PR?
    
    - New public API on the push decoder: `RowGroupSelection` (a row group
    index plus an optional row-group-local `RowSelection`) and
    `ParquetPushDecoderBuilder::with_row_group_selections`. Entries decode
    in the supplied order, omitted row groups are skipped, `None` reads the
    whole row group, and each selection keeps its bitmap or selector
    representation.
    - Mutually exclusive with `with_row_groups` / `with_row_selection`: the
    setters share an internal state machine (`RowGroupPlan`) that reports
    conflicting combinations as an error from `build()` regardless of call
    order. The legacy API combination is unchanged.
    - `build()` validates per-row-group plans eagerly: out-of-bounds indices
    and selections longer than their row group are errors; shorter
    selections skip the trailing rows.
    - `ParquetPushDecoder::into_builder` preserves remaining local
    selections (still in local coordinates), so adaptive scans compose with
    the new API.
    - Minor behavior improvement: an out-of-bounds index from
    `with_row_groups` on the push decoder now returns a `ParquetError`
    during decoding instead of panicking.
    
    The sync and async builders are unchanged; the async builder already
    delegates to the push decoder, so extending the API to it is a small
    follow-up if needed.
    
    # Are these changes tested?
    
    Yes, new tests cover bitmap- and selector-backed local selections
    (including out-of-order row groups and short selections), skip/replace
    semantics, mutual exclusion in all four call orders, build-time
    validation, `into_builder` round-trips, and the unchanged legacy
    combination. All existing tests pass.
    
    # Are there any user-facing changes?
    
    New public API: `RowGroupSelection` and
    `ParquetPushDecoderBuilder::with_row_group_selections`, with doc
    examples. No breaking changes; one behavior change: out-of-bounds
    `with_row_groups` indices on the push decoder now error during decoding
    instead of panicking.
---
 parquet/src/arrow/arrow_reader/mod.rs       | 241 ++++++++++++++--
 parquet/src/arrow/async_reader/mod.rs       |  76 ++++-
 parquet/src/arrow/push_decoder/mod.rs       | 421 ++++++++++++++++++++++++++--
 parquet/src/arrow/push_decoder/remaining.rs | 415 ++++++++++++++++++++++-----
 parquet/src/file/metadata/mod.rs            |  18 ++
 5 files changed, 1054 insertions(+), 117 deletions(-)

diff --git a/parquet/src/arrow/arrow_reader/mod.rs 
b/parquet/src/arrow/arrow_reader/mod.rs
index 6374b46155..006c1f452c 100644
--- a/parquet/src/arrow/arrow_reader/mod.rs
+++ b/parquet/src/arrow/arrow_reader/mod.rs
@@ -64,6 +64,120 @@ pub mod statistics;
 /// Default batch size for reading parquet files
 pub const DEFAULT_BATCH_SIZE: usize = 1024;
 
+/// A row group and its optional row-group-local [`RowSelection`].
+///
+/// A row-group-local selection is relative to the rows in this row group. For
+/// example, an offset of 100 refers to the row at offset 100 within the row
+/// group, not within the Parquet file.
+///
+/// A `None` selection reads the entire row group. Omitting a row group skips
+/// it. Entries are decoded in the supplied order.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RowGroupSelection {
+    pub(crate) row_group_index: usize,
+    pub(crate) selection: Option<RowSelection>,
+}
+
+impl RowGroupSelection {
+    /// Creates a row-group-local selection.
+    pub fn new(row_group_index: usize, selection: Option<RowSelection>) -> 
Self {
+        Self {
+            row_group_index,
+            selection,
+        }
+    }
+
+    /// The index of the row group this selection applies to.
+    pub fn row_group_index(&self) -> usize {
+        self.row_group_index
+    }
+
+    /// The row-group-local selection, or `None` if the entire row group is
+    /// read.
+    pub fn selection(&self) -> Option<&RowSelection> {
+        self.selection.as_ref()
+    }
+}
+
+/// Row-selection configuration shared by the Arrow reader builders.
+#[derive(Debug)]
+pub(crate) enum RowGroupPlan {
+    /// First select `row_groups`, if provided, and then apply `selection`
+    /// across the concatenated rows from those row groups.
+    ///
+    /// This is formed by [`ArrowReaderBuilder::with_row_groups`] and
+    /// [`ArrowReaderBuilder::with_row_selection`].
+    Global {
+        row_groups: Option<Vec<usize>>,
+        selection: Option<RowSelection>,
+    },
+    /// Apply each row-group-local selection independently, in the order
+    /// supplied.
+    ///
+    /// This is formed by `with_row_group_selections` on the push decoder and
+    /// async stream builders.
+    PerRowGroup(Vec<RowGroupSelection>),
+    /// Mutually exclusive global and per-row-group configuration was supplied.
+    /// This is reported as an error when the reader is built.
+    Conflicting,
+}
+
+impl RowGroupPlan {
+    fn set_row_groups(&mut self, new_row_groups: Vec<usize>) {
+        match self {
+            Self::Global { row_groups, .. } => *row_groups = 
Some(new_row_groups),
+            Self::PerRowGroup(_) => *self = Self::Conflicting,
+            Self::Conflicting => {}
+        }
+    }
+
+    fn set_row_selection(&mut self, new_selection: RowSelection) {
+        match self {
+            Self::Global { selection, .. } => *selection = Some(new_selection),
+            Self::PerRowGroup(_) => *self = Self::Conflicting,
+            Self::Conflicting => {}
+        }
+    }
+
+    pub(crate) fn set_row_group_selections(
+        &mut self,
+        row_group_selections: Vec<RowGroupSelection>,
+    ) {
+        match self {
+            Self::Global {
+                row_groups: None,
+                selection: None,
+            }
+            | Self::PerRowGroup(_) => {
+                *self = Self::PerRowGroup(row_group_selections);
+            }
+            Self::Global { .. } => *self = Self::Conflicting,
+            Self::Conflicting => {}
+        }
+    }
+
+    pub(crate) fn conflict_error() -> ParquetError {
+        ParquetError::General(
+            "with_row_group_selections cannot be combined with with_row_groups 
or with_row_selection"
+                .to_string(),
+        )
+    }
+
+    fn into_global(self) -> Result<(Option<Vec<usize>>, Option<RowSelection>)> 
{
+        match self {
+            Self::Global {
+                row_groups,
+                selection,
+            } => Ok((row_groups, selection)),
+            Self::PerRowGroup(_) => Err(ParquetError::General(
+                "Row-group-local selections are not supported by the 
synchronous reader"
+                    .to_string(),
+            )),
+            Self::Conflicting => Err(Self::conflict_error()),
+        }
+    }
+}
+
 /// Builder for constructing Parquet readers that decode into [Apache Arrow]
 /// arrays.
 ///
@@ -130,14 +244,12 @@ pub struct ArrowReaderBuilder<T> {
 
     pub(crate) batch_size: usize,
 
-    pub(crate) row_groups: Option<Vec<usize>>,
+    pub(crate) row_group_plan: RowGroupPlan,
 
     pub(crate) projection: ProjectionMask,
 
     pub(crate) filter: Option<RowFilter>,
 
-    pub(crate) selection: Option<RowSelection>,
-
     pub(crate) row_selection_policy: RowSelectionPolicy,
 
     pub(crate) limit: Option<usize>,
@@ -157,10 +269,9 @@ impl<T: Debug> Debug for ArrowReaderBuilder<T> {
             .field("schema", &self.schema)
             .field("fields", &self.fields)
             .field("batch_size", &self.batch_size)
-            .field("row_groups", &self.row_groups)
+            .field("row_group_plan", &self.row_group_plan)
             .field("projection", &self.projection)
             .field("filter", &self.filter)
-            .field("selection", &self.selection)
             .field("row_selection_policy", &self.row_selection_policy)
             .field("limit", &self.limit)
             .field("offset", &self.offset)
@@ -177,10 +288,12 @@ impl<T> ArrowReaderBuilder<T> {
             schema: metadata.schema,
             fields: metadata.fields,
             batch_size: DEFAULT_BATCH_SIZE,
-            row_groups: None,
+            row_group_plan: RowGroupPlan::Global {
+                row_groups: None,
+                selection: None,
+            },
             projection: ProjectionMask::all(),
             filter: None,
-            selection: None,
             row_selection_policy: RowSelectionPolicy::default(),
             limit: None,
             offset: None,
@@ -219,11 +332,17 @@ impl<T> ArrowReaderBuilder<T> {
     /// Only read data from the provided row group indexes
     ///
     /// This is also called row group filtering
-    pub fn with_row_groups(self, row_groups: Vec<usize>) -> Self {
-        Self {
-            row_groups: Some(row_groups),
-            ..self
-        }
+    ///
+    /// On [`ParquetPushDecoderBuilder`] and 
[`ParquetRecordBatchStreamBuilder`],
+    /// which additionally offer `with_row_group_selections`, this cannot be
+    /// combined with that method; attempting to do so returns an error from
+    /// `build`.
+    ///
+    /// [`ParquetPushDecoderBuilder`]: 
crate::arrow::push_decoder::ParquetPushDecoderBuilder
+    /// [`ParquetRecordBatchStreamBuilder`]: 
crate::arrow::async_reader::ParquetRecordBatchStreamBuilder
+    pub fn with_row_groups(mut self, row_groups: Vec<usize>) -> Self {
+        self.row_group_plan.set_row_groups(row_groups);
+        self
     }
 
     /// Only read data from the provided column indexes
@@ -259,6 +378,14 @@ impl<T> ArrowReaderBuilder<T> {
     /// applying the row selection, and therefore rows from skipped row groups
     /// should not be included in the [`RowSelection`] (see example below)
     ///
+    /// On [`ParquetPushDecoderBuilder`] and 
[`ParquetRecordBatchStreamBuilder`],
+    /// which additionally offer `with_row_group_selections`, this cannot be
+    /// combined with that method; attempting to do so returns an error from
+    /// `build`.
+    ///
+    /// [`ParquetPushDecoderBuilder`]: 
crate::arrow::push_decoder::ParquetPushDecoderBuilder
+    /// [`ParquetRecordBatchStreamBuilder`]: 
crate::arrow::async_reader::ParquetRecordBatchStreamBuilder
+    ///
     /// It is recommended to enable writing the page index if using this
     /// functionality, to allow more efficient skipping over data pages. See
     /// [`ArrowReaderOptions::with_page_index_policy`].
@@ -303,11 +430,9 @@ impl<T> ArrowReaderBuilder<T> {
     /// ```
     ///
     /// [`Index`]: crate::file::page_index::column_index::ColumnIndexMetaData
-    pub fn with_row_selection(self, selection: RowSelection) -> Self {
-        Self {
-            selection: Some(selection),
-            ..self
-        }
+    pub fn with_row_selection(mut self, selection: RowSelection) -> Self {
+        self.row_group_plan.set_row_selection(selection);
+        self
     }
 
     /// Provide a [`RowFilter`] to skip decoding rows
@@ -1199,10 +1324,9 @@ impl<T: ChunkReader + 'static> 
ParquetRecordBatchReaderBuilder<T> {
             schema: _,
             fields,
             batch_size,
-            row_groups,
+            row_group_plan,
             projection,
             mut filter,
-            selection,
             row_selection_policy,
             limit,
             offset,
@@ -1214,6 +1338,8 @@ impl<T: ChunkReader + 'static> 
ParquetRecordBatchReaderBuilder<T> {
         // Try to avoid allocate large buffer
         let batch_size = batch_size.min(metadata.file_metadata().num_rows() as 
usize);
 
+        let (row_groups, selection) = row_group_plan.into_global()?;
+
         let row_groups = row_groups.unwrap_or_else(|| 
(0..metadata.num_row_groups()).collect());
 
         let reader = ReaderRowGroups {
@@ -1670,7 +1796,8 @@ pub(crate) mod tests {
 
     use crate::arrow::arrow_reader::{
         ArrowPredicateFn, ArrowReaderMetadata, ArrowReaderOptions, 
ParquetRecordBatchReader,
-        ParquetRecordBatchReaderBuilder, RowFilter, RowSelection, RowSelector,
+        ParquetRecordBatchReaderBuilder, RowFilter, RowGroupPlan, 
RowGroupSelection, RowSelection,
+        RowSelector,
     };
     use crate::arrow::schema::{
         add_encoded_arrow_schema_to_metadata,
@@ -1706,6 +1833,80 @@ pub(crate) mod tests {
     use half::f16;
     use num_traits::PrimInt;
 
+    fn row_selection(rows: usize) -> RowSelection {
+        RowSelection::from(vec![RowSelector::select(rows)])
+    }
+
+    #[test]
+    fn row_group_selection_accessors() {
+        let row_group = RowGroupSelection::new(3, Some(row_selection(5)));
+        assert_eq!(row_group.row_group_index(), 3);
+        assert_eq!(row_group.selection().unwrap().row_count(), 5);
+
+        let row_group = RowGroupSelection::new(4, None);
+        assert_eq!(row_group.row_group_index(), 4);
+        assert!(row_group.selection().is_none());
+    }
+
+    #[test]
+    fn row_group_plan_tracks_global_configuration() {
+        let mut plan = RowGroupPlan::Global {
+            row_groups: None,
+            selection: None,
+        };
+        plan.set_row_groups(vec![0]);
+        plan.set_row_groups(vec![1, 2]);
+        plan.set_row_selection(row_selection(3));
+        plan.set_row_selection(row_selection(4));
+
+        let (row_groups, selection) = plan.into_global().unwrap();
+        assert_eq!(row_groups, Some(vec![1, 2]));
+        assert_eq!(selection.unwrap().row_count(), 4);
+    }
+
+    #[test]
+    fn row_group_plan_replaces_local_configuration() {
+        let mut plan = RowGroupPlan::Global {
+            row_groups: None,
+            selection: None,
+        };
+        plan.set_row_group_selections(vec![RowGroupSelection::new(0, None)]);
+        plan.set_row_group_selections(vec![RowGroupSelection::new(1, None)]);
+
+        let RowGroupPlan::PerRowGroup(row_groups) = plan else {
+            panic!("expected per-row-group plan");
+        };
+        assert_eq!(row_groups, vec![RowGroupSelection::new(1, None)]);
+    }
+
+    #[test]
+    fn row_group_plan_rejects_mixed_configuration() {
+        let mut row_groups_then_local = RowGroupPlan::Global {
+            row_groups: None,
+            selection: None,
+        };
+        row_groups_then_local.set_row_groups(vec![0]);
+        
row_groups_then_local.set_row_group_selections(vec![RowGroupSelection::new(0, 
None)]);
+        assert!(matches!(row_groups_then_local, RowGroupPlan::Conflicting));
+        row_groups_then_local.set_row_groups(vec![1]);
+        row_groups_then_local.set_row_selection(row_selection(1));
+        
row_groups_then_local.set_row_group_selections(vec![RowGroupSelection::new(1, 
None)]);
+        assert!(row_groups_then_local.into_global().is_err());
+
+        let mut local_then_row_groups =
+            RowGroupPlan::PerRowGroup(vec![RowGroupSelection::new(0, None)]);
+        local_then_row_groups.set_row_groups(vec![0]);
+        assert!(matches!(local_then_row_groups, RowGroupPlan::Conflicting));
+
+        let mut local_then_selection =
+            RowGroupPlan::PerRowGroup(vec![RowGroupSelection::new(0, None)]);
+        local_then_selection.set_row_selection(row_selection(1));
+        assert!(matches!(local_then_selection, RowGroupPlan::Conflicting));
+
+        let local = RowGroupPlan::PerRowGroup(vec![RowGroupSelection::new(0, 
None)]);
+        assert!(local.into_global().is_err());
+    }
+
     #[test]
     fn filter_mask_accumulator_handles_empty_single_and_multiple_chunks() {
         let first = BooleanBuffer::from(vec![true, false, false, false]);
diff --git a/parquet/src/arrow/async_reader/mod.rs 
b/parquet/src/arrow/async_reader/mod.rs
index 85c8fa463a..3eddd54935 100644
--- a/parquet/src/arrow/async_reader/mod.rs
+++ b/parquet/src/arrow/async_reader/mod.rs
@@ -53,6 +53,10 @@ pub use metadata::*;
 mod spawn;
 pub use spawn::SpawnedReader;
 
+/// Re-exported so 
[`ParquetRecordBatchStreamBuilder::with_row_group_selections`]
+/// can be used without importing from another module.
+pub use crate::arrow::arrow_reader::RowGroupSelection;
+
 #[cfg(feature = "object_store")]
 mod store;
 
@@ -643,6 +647,29 @@ impl<T: AsyncFileReader + Send + 'static> 
ParquetRecordBatchStreamBuilder<T> {
         Ok(Some(Sbbf::new(&bitset)))
     }
 
+    /// Select row groups and rows using row-group-local coordinates.
+    ///
+    /// Entries are decoded in the supplied order, omitted row groups are
+    /// skipped, and a `None` selection reads the whole row group. This is
+    /// mutually exclusive with [`ArrowReaderBuilder::with_row_groups`] and
+    /// [`ArrowReaderBuilder::with_row_selection`]; combining them returns an
+    /// error from [`Self::build`].
+    ///
+    /// See [`ParquetPushDecoderBuilder::with_row_group_selections`] for the
+    /// full semantics and a worked example. This builder supports the same API
+    /// because the async stream is implemented using the push decoder; the
+    /// synchronous reader does not support row-group-local selections.
+    ///
+    /// [`ParquetPushDecoderBuilder::with_row_group_selections`]: 
crate::arrow::push_decoder::ParquetPushDecoderBuilder::with_row_group_selections
+    pub fn with_row_group_selections(
+        mut self,
+        row_group_selections: Vec<RowGroupSelection>,
+    ) -> Self {
+        self.row_group_plan
+            .set_row_group_selections(row_group_selections);
+        self
+    }
+
     /// Build a new [`ParquetRecordBatchStream`]
     ///
     /// See examples on [`ParquetRecordBatchStreamBuilder::new`]
@@ -653,10 +680,9 @@ impl<T: AsyncFileReader + Send + 'static> 
ParquetRecordBatchStreamBuilder<T> {
             schema,
             fields,
             batch_size,
-            row_groups,
+            row_group_plan,
             projection,
             filter,
-            selection,
             row_selection_policy: selection_strategy,
             limit,
             offset,
@@ -679,10 +705,9 @@ impl<T: AsyncFileReader + Send + 'static> 
ParquetRecordBatchStreamBuilder<T> {
             fields,
             projection,
             filter,
-            selection,
+            row_group_plan,
             row_selection_policy: selection_strategy,
             batch_size,
-            row_groups,
             limit,
             offset,
             metrics,
@@ -1052,6 +1077,49 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_async_reader_row_group_local_selections() {
+        let batch = RecordBatch::try_from_iter([(
+            "a",
+            Arc::new(Int32Array::from_iter_values(0..6)) as ArrayRef,
+        )])
+        .unwrap();
+        let mut data = Vec::new();
+        let properties = WriterProperties::builder()
+            .set_max_row_group_row_count(Some(3))
+            .build();
+        let mut writer = ArrowWriter::try_new(&mut data, batch.schema(), 
Some(properties)).unwrap();
+        writer.write(&batch).unwrap();
+        writer.close().unwrap();
+
+        let stream = 
ParquetRecordBatchStreamBuilder::new(TestReader::new(data.into()))
+            .await
+            .unwrap()
+            .with_row_group_selections(vec![
+                RowGroupSelection::new(1, 
Some(RowSelection::from(vec![RowSelector::select(1)]))),
+                RowGroupSelection::new(
+                    0,
+                    Some(RowSelection::from(vec![
+                        RowSelector::skip(1),
+                        RowSelector::select(2),
+                    ])),
+                ),
+            ])
+            .build()
+            .unwrap();
+
+        let batches: Vec<_> = stream.try_collect().await.unwrap();
+        assert_eq!(batches.len(), 2);
+        assert_eq!(
+            batches[0].column(0).as_primitive::<Int32Type>().values(),
+            &[3]
+        );
+        assert_eq!(
+            batches[1].column(0).as_primitive::<Int32Type>().values(),
+            &[1, 2]
+        );
+    }
+
     #[tokio::test]
     async fn test_async_reader_with_next_row_group() {
         let testdata = arrow::util::test_util::parquet_test_data();
diff --git a/parquet/src/arrow/push_decoder/mod.rs 
b/parquet/src/arrow/push_decoder/mod.rs
index 54f4e2d331..402a600274 100644
--- a/parquet/src/arrow/push_decoder/mod.rs
+++ b/parquet/src/arrow/push_decoder/mod.rs
@@ -22,6 +22,7 @@ mod reader_builder;
 mod remaining;
 
 use crate::DecodeResult;
+pub use crate::arrow::arrow_reader::RowGroupSelection;
 use crate::arrow::arrow_reader::{
     ArrowReaderBuilder, ArrowReaderMetadata, ArrowReaderOptions, 
ParquetRecordBatchReader,
 };
@@ -249,6 +250,58 @@ impl ParquetPushDecoderBuilder {
         }
     }
 
+    /// Select row groups and rows using row-group-local coordinates.
+    ///
+    /// Entries are decoded in the supplied order, and omitted row groups are
+    /// skipped. A row group listed more than once is decoded once per entry.
+    /// A `None` selection reads the entire row group. A selection shorter
+    /// than its row group skips the trailing rows, while a selection longer
+    /// than its row group returns an error from [`Self::build`].
+    ///
+    /// [`ArrowReaderBuilder::with_offset`] and
+    /// [`ArrowReaderBuilder::with_limit`] apply after the row-group-local
+    /// selections and any [`ArrowReaderBuilder::with_row_filter`], across the
+    /// plan as a whole and in the supplied order: the offset skips the first N
+    /// remaining rows and the limit caps the total rows emitted, regardless of
+    /// which row group they come from.
+    ///
+    /// This configuration is mutually exclusive with
+    /// [`ArrowReaderBuilder::with_row_groups`] and
+    /// [`ArrowReaderBuilder::with_row_selection`]. Combining them returns an
+    /// error from [`Self::build`]. Calling this method more than once replaces
+    /// the previous row-group-local configuration.
+    ///
+    /// This method is defined on the push decoder rather than
+    /// [`ArrowReaderBuilder`] because the synchronous reader does not support
+    /// row-group-local selections. The async stream builder exposes the same
+    /// method because it uses the push decoder internally.
+    ///
+    /// For example, if row groups 0 and 2 each contain 200 rows, the legacy
+    /// combination of `with_row_groups(vec![0, 2])` and a global selection for
+    /// rows 10..15 of row group 0 and all of row group 2 can be expressed as:
+    ///
+    /// ```no_run
+    /// # use parquet::arrow::arrow_reader::{RowSelection, RowSelector};
+    /// # use parquet::arrow::push_decoder::{ParquetPushDecoderBuilder, 
RowGroupSelection};
+    /// # fn configure(builder: ParquetPushDecoderBuilder) -> 
ParquetPushDecoderBuilder {
+    /// builder.with_row_group_selections(vec![
+    ///     RowGroupSelection::new(0, Some(RowSelection::from(vec![
+    ///         RowSelector::skip(10),
+    ///         RowSelector::select(5),
+    ///     ]))),
+    ///     RowGroupSelection::new(2, None),
+    /// ])
+    /// # }
+    /// ```
+    pub fn with_row_group_selections(
+        mut self,
+        row_group_selections: Vec<RowGroupSelection>,
+    ) -> Self {
+        self.row_group_plan
+            .set_row_group_selections(row_group_selections);
+        self
+    }
+
     /// Create a [`ParquetPushDecoder`] with the configured options
     pub fn build(self) -> Result<ParquetPushDecoder, ParquetError> {
         let Self {
@@ -257,10 +310,9 @@ impl ParquetPushDecoderBuilder {
             schema,
             fields,
             batch_size,
-            row_groups,
+            row_group_plan,
             projection,
             filter,
-            selection,
             limit,
             offset,
             metrics,
@@ -268,9 +320,6 @@ impl ParquetPushDecoderBuilder {
             max_predicate_cache_size,
         } = self;
 
-        // If no row groups were specified, read all of them
-        let row_groups =
-            row_groups.unwrap_or_else(|| 
(0..parquet_metadata.num_row_groups()).collect());
         let has_predicates = filter
             .as_ref()
             .is_some_and(|filter| !filter.predicates.is_empty());
@@ -294,12 +343,11 @@ impl ParquetPushDecoderBuilder {
         let remaining_row_groups = RemainingRowGroups::new(
             schema,
             parquet_metadata,
-            row_groups,
-            selection,
+            row_group_plan,
             RowBudget::new(offset, limit),
             has_predicates,
             row_group_reader_builder,
-        );
+        )?;
 
         Ok(ParquetPushDecoder {
             state: ParquetDecoderState::ReadingRowGroup {
@@ -311,14 +359,13 @@ impl ParquetPushDecoderBuilder {
 
 /// Reassemble a [`ParquetPushDecoderBuilder`] from a decoder's not-yet-decoded
 /// state — the inverse of [`ParquetPushDecoderBuilder::build`]. The rebuilt
-/// builder pins the remaining row groups and carries the remaining row
-/// selection, offset/limit budget, and buffered bytes.
+/// builder carries the remaining row-group plan, offset/limit budget, and
+/// buffered bytes.
 fn builder_from_remaining(parts: RemainingRowGroupsParts) -> 
ParquetPushDecoderBuilder {
     let RemainingRowGroupsParts {
         metadata,
         schema,
-        row_groups,
-        selection,
+        row_group_plan,
         offset,
         limit,
         reader_builder,
@@ -340,13 +387,9 @@ fn builder_from_remaining(parts: RemainingRowGroupsParts) 
-> ParquetPushDecoderB
         schema,
         fields,
         batch_size,
-        // The frontier tracks remaining row groups explicitly, so the rebuilt
-        // builder always pins them (even if the original left `row_groups` as
-        // `None` meaning "all").
-        row_groups: Some(row_groups),
+        row_group_plan,
         projection,
         filter,
-        selection,
         row_selection_policy,
         limit,
         offset,
@@ -591,13 +634,20 @@ impl ParquetPushDecoder {
     /// }
     /// ```
     ///
-    /// The returned builder pins the not-yet-decoded row groups (via
-    /// [`with_row_groups`](ArrowReaderBuilder::with_row_groups)) and carries 
the
-    /// not-yet-consumed row selection and offset/limit budget, so rows from
-    /// already-decoded row groups are not produced again. Every other option —
-    /// projection, row filter, row selection policy, batch size, metrics,
-    /// predicate-cache size — is left exactly as the decoder had it and can be
-    /// overridden before [`build`](ParquetPushDecoderBuilder::build).
+    /// The returned builder preserves the not-yet-decoded row-group plan,
+    /// including any row-group-local selections, together with the remaining
+    /// offset/limit budget. Rows from already-decoded row groups are not
+    /// produced again. Every other option — projection, row filter, row
+    /// selection policy, batch size, metrics, predicate-cache size — is left
+    /// exactly as the decoder had it and can be overridden before
+    /// [`build`](ParquetPushDecoderBuilder::build).
+    ///
+    /// Because the preserved plan pins the remaining row groups, the mutual
+    /// exclusion documented on
+    /// 
[`with_row_group_selections`](ParquetPushDecoderBuilder::with_row_group_selections)
+    /// applies to the rebuilt builder as well: calling it on a builder rebuilt
+    /// from a decoder configured with `with_row_groups` / `with_row_selection`
+    /// (or vice versa) returns an error from `build`.
     ///
     /// # Errors
     ///
@@ -917,7 +967,9 @@ mod test {
     use super::*;
     use crate::DecodeResult;
     use crate::arrow::arrow_reader::{ArrowPredicateFn, RowFilter, 
RowSelection, RowSelector};
-    use crate::arrow::push_decoder::{ParquetPushDecoder, 
ParquetPushDecoderBuilder};
+    use crate::arrow::push_decoder::{
+        ParquetPushDecoder, ParquetPushDecoderBuilder, RowGroupSelection,
+    };
     use crate::arrow::{ArrowWriter, ProjectionMask};
     use crate::errors::ParquetError;
     use crate::file::metadata::ParquetMetaDataPushDecoder;
@@ -926,6 +978,7 @@ mod test {
     use arrow_array::cast::AsArray;
     use arrow_array::types::Int64Type;
     use arrow_array::{ArrayRef, Int64Array, RecordBatch, StringViewArray};
+    use arrow_buffer::BooleanBuffer;
     use arrow_select::concat::concat_batches;
     use bytes::Bytes;
     use std::fmt::Debug;
@@ -1787,6 +1840,284 @@ mod test {
         expect_finished(decoder.try_decode());
     }
 
+    #[test]
+    fn test_decoder_row_group_local_selections() {
+        let bitmap_selection = 
RowSelection::from_boolean_buffer(BooleanBuffer::from(
+            (0..45)
+                .map(|row_idx| (25..45).contains(&row_idx))
+                .collect::<Vec<_>>(),
+        ));
+        let rle_selection =
+            RowSelection::from(vec![RowSelector::skip(190), 
RowSelector::select(10)]);
+
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_group_selections(vec![
+                RowGroupSelection::new(1, Some(bitmap_selection)),
+                RowGroupSelection::new(0, Some(rle_selection)),
+            ])
+            .build()
+            .unwrap();
+        prefetch_test_file(&mut decoder);
+
+        // Row-group-local selections use local coordinates and preserve the
+        // supplied row-group order. The bitmap is intentionally shorter than
+        // RG1, so rows after its 45th local row are skipped.
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(225, 
20));
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(190, 
10));
+        expect_finished(decoder.try_decode());
+    }
+
+    #[test]
+    fn test_row_group_local_selections_respect_offset_and_limit() {
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_group_selections(vec![
+                RowGroupSelection::new(1, None),
+                RowGroupSelection::new(0, None),
+            ])
+            .with_offset(195)
+            .with_limit(10)
+            .build()
+            .unwrap();
+        prefetch_test_file(&mut decoder);
+
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(395, 
5));
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(0, 5));
+        expect_finished(decoder.try_decode());
+    }
+
+    #[test]
+    fn test_row_group_local_selections_allow_duplicate_row_groups() {
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_group_selections(vec![
+                RowGroupSelection::new(0, None),
+                RowGroupSelection::new(0, None),
+            ])
+            .build()
+            .unwrap();
+        prefetch_test_file(&mut decoder);
+
+        let expected = TEST_BATCH.slice(0, 200);
+        assert_eq!(expect_data(decoder.try_decode()), expected);
+        assert_eq!(expect_data(decoder.try_decode()), expected);
+        expect_finished(decoder.try_decode());
+    }
+
+    #[test]
+    fn test_short_row_group_local_selection_skips_trailing_rows() {
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_group_selections(vec![RowGroupSelection::new(
+                1,
+                Some(RowSelection::from(vec![
+                    RowSelector::skip(5),
+                    RowSelector::select(3),
+                ])),
+            )])
+            .build()
+            .unwrap();
+        prefetch_test_file(&mut decoder);
+
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(205, 
3));
+        expect_finished(decoder.try_decode());
+    }
+
+    #[test]
+    fn test_empty_row_group_local_selections_read_nothing() {
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_group_selections(vec![])
+            .build()
+            .unwrap();
+
+        expect_finished(decoder.try_decode());
+    }
+
+    /// A `RowFilter` narrows each row group's local selection rather than
+    /// replacing it: the predicate is evaluated against the locally selected
+    /// rows, including when the selection is shorter than its row group and
+    /// the row groups are supplied out of order.
+    ///
+    /// RG1 contributes local rows 10..20 ("a" 210..220), all of which pass
+    /// `a > 195`; RG0 contributes local rows 190..200 ("a" 190..200), of which
+    /// only 196..200 pass.
+    fn row_group_local_selections_with_filter() -> ParquetPushDecoderBuilder {
+        let builder =
+            
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata()).unwrap();
+        let schema_descr = 
builder.metadata().file_metadata().schema_descr_ptr();
+
+        // Values in column "a" range 0..399
+        let row_filter_a = ArrowPredicateFn::new(
+            ProjectionMask::columns(&schema_descr, ["a"]),
+            |batch: RecordBatch| {
+                let scalar_195 = Int64Array::new_scalar(195);
+                let column = batch.column(0).as_primitive::<Int64Type>();
+                gt(column, &scalar_195)
+            },
+        );
+
+        builder
+            .with_row_filter(RowFilter::new(vec![Box::new(row_filter_a)]))
+            .with_row_group_selections(vec![
+                RowGroupSelection::new(
+                    1,
+                    Some(RowSelection::from(vec![
+                        RowSelector::skip(10),
+                        RowSelector::select(10),
+                    ])),
+                ),
+                RowGroupSelection::new(
+                    0,
+                    Some(RowSelection::from(vec![
+                        RowSelector::skip(190),
+                        RowSelector::select(10),
+                    ])),
+                ),
+            ])
+    }
+
+    #[test]
+    fn test_row_group_local_selections_with_row_filter() {
+        let mut decoder = 
row_group_local_selections_with_filter().build().unwrap();
+        prefetch_test_file(&mut decoder);
+
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(210, 
10));
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(196, 
4));
+        expect_finished(decoder.try_decode());
+    }
+
+    /// `into_builder` mid-scan must carry both the row filter and the
+    /// still-local selection of the not-yet-decoded row group, so the rebuilt
+    /// decoder produces exactly what an uninterrupted scan would have.
+    #[test]
+    fn test_into_builder_preserves_local_selections_with_row_filter() {
+        let mut decoder = 
row_group_local_selections_with_filter().build().unwrap();
+        prefetch_test_file(&mut decoder);
+
+        let reader1 = expect_data(decoder.try_next_reader());
+        let batches1: Vec<_> = reader1.collect::<Result<_, _>>().unwrap();
+        let batch1 = concat_batches(&TEST_BATCH.schema(), &batches1).unwrap();
+        assert_eq!(batch1, TEST_BATCH.slice(210, 10));
+
+        // Only RG0 and its local `skip(190) + select(10)` remain.
+        assert!(decoder.is_at_row_group_boundary());
+        assert_eq!(decoder.row_groups_remaining(), 1);
+        let mut decoder = decoder.into_builder().unwrap().build().unwrap();
+
+        let reader0 = expect_data(decoder.try_next_reader());
+        let batches0: Vec<_> = reader0.collect::<Result<_, _>>().unwrap();
+        let batch0 = concat_batches(&TEST_BATCH.schema(), &batches0).unwrap();
+        assert_eq!(batch0, TEST_BATCH.slice(196, 4));
+        expect_finished(decoder.try_next_reader());
+    }
+
+    #[test]
+    fn test_row_group_local_selection_replaces_previous_configuration() {
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_group_selections(vec![RowGroupSelection::new(0, None)])
+            .with_row_group_selections(vec![RowGroupSelection::new(1, None)])
+            .build()
+            .unwrap();
+        prefetch_test_file(&mut decoder);
+
+        // The second call replaces the first. `None` reads RG1 in full, and
+        // the omitted RG0 is skipped.
+        assert_eq!(
+            expect_data(decoder.try_decode()),
+            TEST_BATCH.slice(200, 200)
+        );
+        expect_finished(decoder.try_decode());
+    }
+
+    #[test]
+    fn test_legacy_row_groups_and_row_selection_remain_composable() {
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_groups(vec![1])
+            .with_row_selection(RowSelection::from(vec![
+                RowSelector::skip(25),
+                RowSelector::select(20),
+            ]))
+            .build()
+            .unwrap();
+        prefetch_test_file(&mut decoder);
+
+        assert_eq!(expect_data(decoder.try_decode()), TEST_BATCH.slice(225, 
20));
+        expect_finished(decoder.try_decode());
+    }
+
+    #[test]
+    fn 
test_row_group_local_selection_is_mutually_exclusive_with_legacy_configuration()
 {
+        let metadata = test_file_parquet_metadata();
+        let new_builder =
+            || 
ParquetPushDecoderBuilder::try_new_decoder(Arc::clone(&metadata)).unwrap();
+        let local_selection = || vec![RowGroupSelection::new(0, None)];
+        let global_selection = || 
RowSelection::from(vec![RowSelector::select(1)]);
+        let assert_conflict = |builder: ParquetPushDecoderBuilder| {
+            let error = builder.build().unwrap_err();
+            assert_eq!(
+                error.to_string(),
+                "Parquet error: with_row_group_selections cannot be combined 
with with_row_groups or with_row_selection"
+            );
+        };
+
+        assert_conflict(
+            new_builder()
+                .with_row_groups(vec![0])
+                .with_row_group_selections(local_selection()),
+        );
+        assert_conflict(
+            new_builder()
+                .with_row_group_selections(local_selection())
+                .with_row_groups(vec![0]),
+        );
+        assert_conflict(
+            new_builder()
+                .with_row_selection(global_selection())
+                .with_row_group_selections(local_selection()),
+        );
+        assert_conflict(
+            new_builder()
+                .with_row_group_selections(local_selection())
+                .with_row_selection(global_selection()),
+        );
+    }
+
+    #[test]
+    fn 
test_row_group_local_selection_validates_row_group_and_length_at_build() {
+        let metadata = test_file_parquet_metadata();
+
+        let error = 
ParquetPushDecoderBuilder::try_new_decoder(Arc::clone(&metadata))
+            .unwrap()
+            .with_row_group_selections(vec![RowGroupSelection::new(
+                0,
+                Some(RowSelection::from(vec![RowSelector::select(201)])),
+            )])
+            .build()
+            .unwrap_err();
+        assert!(
+            error.to_string().contains(
+                "Row selection for row group 0 contains 201 rows, but the row 
group has 200"
+            ),
+            "unexpected error: {error}"
+        );
+
+        let error = ParquetPushDecoderBuilder::try_new_decoder(metadata)
+            .unwrap()
+            .with_row_group_selections(vec![RowGroupSelection::new(2, None)])
+            .build()
+            .unwrap_err();
+        assert!(
+            error
+                .to_string()
+                .contains("Row group index 2 out of bounds for file with 2 row 
groups"),
+            "unexpected error: {error}"
+        );
+    }
+
     /// `peek_next_row_group` reports the index of the row group the
     /// next `try_next_reader` call will hand back, matching the
     /// frontier's internal skip logic.
@@ -2152,6 +2483,46 @@ mod test {
         expect_finished(decoder.try_next_reader());
     }
 
+    #[test]
+    fn test_into_builder_preserves_remaining_row_group_local_selections() {
+        let bitmap_selection = 
RowSelection::from_boolean_buffer(BooleanBuffer::from(
+            (0..45)
+                .map(|row_idx| (25..45).contains(&row_idx))
+                .collect::<Vec<_>>(),
+        ));
+        let mut decoder = 
ParquetPushDecoderBuilder::try_new_decoder(test_file_parquet_metadata())
+            .unwrap()
+            .with_row_group_selections(vec![
+                RowGroupSelection::new(
+                    0,
+                    Some(RowSelection::from(vec![
+                        RowSelector::skip(190),
+                        RowSelector::select(10),
+                    ])),
+                ),
+                RowGroupSelection::new(1, Some(bitmap_selection)),
+            ])
+            .build()
+            .unwrap();
+        prefetch_test_file(&mut decoder);
+
+        let reader0 = expect_data(decoder.try_next_reader());
+        let batches0: Vec<_> = reader0.collect::<Result<_, _>>().unwrap();
+        let batch0 = concat_batches(&TEST_BATCH.schema(), &batches0).unwrap();
+        assert_eq!(batch0, TEST_BATCH.slice(190, 10));
+
+        // Rebuilding must carry only RG1 and its still-local bitmap selection.
+        assert!(decoder.is_at_row_group_boundary());
+        assert_eq!(decoder.row_groups_remaining(), 1);
+        let mut decoder = decoder.into_builder().unwrap().build().unwrap();
+
+        let reader1 = expect_data(decoder.try_next_reader());
+        let batches1: Vec<_> = reader1.collect::<Result<_, _>>().unwrap();
+        let batch1 = concat_batches(&TEST_BATCH.schema(), &batches1).unwrap();
+        assert_eq!(batch1, TEST_BATCH.slice(225, 20));
+        expect_finished(decoder.try_next_reader());
+    }
+
     /// Drive the decoder incrementally. Start with a narrow projection,
     /// drain RG0, then `into_builder` and widen the projection to all three
     /// columns. The rebuilt decoder's `NeedsData` for RG1 must request
diff --git a/parquet/src/arrow/push_decoder/remaining.rs 
b/parquet/src/arrow/push_decoder/remaining.rs
index 09a1cdc95b..6d53045bf9 100644
--- a/parquet/src/arrow/push_decoder/remaining.rs
+++ b/parquet/src/arrow/push_decoder/remaining.rs
@@ -16,7 +16,9 @@
 // under the License.
 
 use crate::DecodeResult;
-use crate::arrow::arrow_reader::{ParquetRecordBatchReader, RowSelection};
+use crate::arrow::arrow_reader::{
+    ParquetRecordBatchReader, RowGroupPlan, RowGroupSelection, RowSelection,
+};
 use crate::arrow::push_decoder::reader_builder::{
     RowBudget, RowGroupBuildResult, RowGroupReaderBuilder, 
RowGroupReaderBuilderParts,
 };
@@ -42,21 +44,145 @@ enum QueuedRowGroupDecision {
 struct NextRowGroup {
     row_group_idx: usize,
     row_count: usize,
-    /// This row group's slice of the global selection, or `None` when all rows
-    /// are selected.
+    /// This row group's selection, or `None` when all rows are selected.
     selection: Option<RowSelection>,
     /// Budget snapshot to apply while decoding this row group.
     budget: RowBudget,
 }
 
+/// Row groups and selections that have not yet been handed to the row-group
+/// reader builder.
+#[derive(Debug, Clone)]
+enum QueuedRowGroups {
+    /// One selection cursor spans all queued row groups.
+    Global {
+        row_groups: VecDeque<usize>,
+        selection: Option<RowSelection>,
+    },
+    /// Selections are already relative to their respective row groups.
+    PerRowGroup(VecDeque<RowGroupSelection>),
+}
+
+impl QueuedRowGroups {
+    /// Validate and queue a row-group plan for `parquet_metadata`.
+    fn try_new(
+        parquet_metadata: &ParquetMetaData,
+        row_group_plan: RowGroupPlan,
+    ) -> Result<Self, ParquetError> {
+        match row_group_plan {
+            RowGroupPlan::Global {
+                row_groups,
+                selection,
+            } => Ok(Self::Global {
+                row_groups: row_groups
+                    .unwrap_or_else(|| 
(0..parquet_metadata.num_row_groups()).collect())
+                    .into(),
+                selection,
+            }),
+            RowGroupPlan::PerRowGroup(row_groups) => {
+                for row_group in &row_groups {
+                    let row_count =
+                        
parquet_metadata.row_group_num_rows(row_group.row_group_index)?;
+                    if let Some(selection) = &row_group.selection {
+                        let selection_rows = selection.total_row_count();
+                        if selection_rows > row_count {
+                            return Err(ParquetError::General(format!(
+                                "Row selection for row group {} contains 
{selection_rows} rows, but the row group has {row_count}",
+                                row_group.row_group_index
+                            )));
+                        }
+                    }
+                }
+                Ok(Self::PerRowGroup(row_groups.into()))
+            }
+            RowGroupPlan::Conflicting => Err(RowGroupPlan::conflict_error()),
+        }
+    }
+
+    /// Convert the remaining queue back into a builder configuration.
+    fn into_plan(self) -> RowGroupPlan {
+        match self {
+            Self::Global {
+                row_groups,
+                selection,
+            } => RowGroupPlan::Global {
+                row_groups: Some(Vec::from(row_groups)),
+                selection,
+            },
+            Self::PerRowGroup(row_groups) => 
RowGroupPlan::PerRowGroup(Vec::from(row_groups)),
+        }
+    }
+
+    fn front(&self) -> Option<usize> {
+        match self {
+            Self::Global { row_groups, .. } => row_groups.front().copied(),
+            Self::PerRowGroup(row_groups) => row_groups
+                .front()
+                .map(|row_group| row_group.row_group_index),
+        }
+    }
+
+    fn len(&self) -> usize {
+        match self {
+            Self::Global { row_groups, .. } => row_groups.len(),
+            Self::PerRowGroup(row_groups) => row_groups.len(),
+        }
+    }
+
+    fn clear(&mut self) {
+        match self {
+            Self::Global {
+                row_groups,
+                selection,
+            } => {
+                row_groups.clear();
+                *selection = None;
+            }
+            Self::PerRowGroup(row_groups) => row_groups.clear(),
+        }
+    }
+
+    /// Returns `true` when a shared global selection has no selected rows 
left.
+    /// Per-row-group selections are independent and are drained one at a time.
+    fn global_selection_is_exhausted(&self) -> bool {
+        matches!(
+            self,
+            Self::Global {
+                selection: Some(selection),
+                ..
+            } if selection.row_count() == 0
+        )
+    }
+
+    /// Remove the front row group and return its local selection.
+    fn pop_front_selection(&mut self, row_count: usize) -> 
Option<RowSelection> {
+        match self {
+            Self::Global {
+                row_groups,
+                selection,
+            } => {
+                let popped = row_groups.pop_front();
+                debug_assert!(popped.is_some(), "front row group checked 
before pop");
+                selection
+                    .as_mut()
+                    .map(|selection| selection.split_off(row_count))
+            }
+            Self::PerRowGroup(row_groups) => {
+                row_groups
+                    .pop_front()
+                    .expect("front row group checked before pop")
+                    .selection
+            }
+        }
+    }
+}
+
 #[derive(Debug, Clone)]
 struct RowGroupFrontier {
     /// Metadata used to resolve row counts for queued row groups.
     parquet_metadata: Arc<ParquetMetaData>,
-    /// Row group indices not yet handed to the builder.
-    row_groups: VecDeque<usize>,
-    /// Cross-row-group cursor for the optional global row selection.
-    selection: Option<RowSelection>,
+    /// Row groups not yet handed to the builder.
+    queued: QueuedRowGroups,
     /// Offset/limit budget before the next readable row group is planned.
     budget: RowBudget,
     /// If predicates are present, row groups with selected rows must be read 
so
@@ -67,26 +193,18 @@ struct RowGroupFrontier {
 impl RowGroupFrontier {
     fn new(
         parquet_metadata: Arc<ParquetMetaData>,
-        row_groups: Vec<usize>,
-        selection: Option<RowSelection>,
+        row_group_plan: RowGroupPlan,
         budget: RowBudget,
         has_predicates: bool,
-    ) -> Self {
-        Self {
+    ) -> Result<Self, ParquetError> {
+        let queued = QueuedRowGroups::try_new(&parquet_metadata, 
row_group_plan)?;
+
+        Ok(Self {
             parquet_metadata,
-            row_groups: VecDeque::from(row_groups),
-            selection,
+            queued,
             budget,
             has_predicates,
-        }
-    }
-
-    fn row_group_num_rows(&self, row_group_idx: usize) -> Result<usize, 
ParquetError> {
-        self.parquet_metadata
-            .row_group(row_group_idx)
-            .num_rows()
-            .try_into()
-            .map_err(|e| ParquetError::General(format!("Row count overflow: 
{e}")))
+        })
     }
 
     fn update_budget_after_row_group(&mut self, budget: RowBudget) {
@@ -100,8 +218,8 @@ impl RowGroupFrontier {
     ///
     /// Runs the real [`Self::next_readable_row_group`] advance logic on a
     /// throwaway clone of the frontier, so peek can never drift from the
-    /// read path. The clone copies the queued row-group indices and optional
-    /// row-selection (a `Vec<RowSelector>`); see
+    /// read path. The clone copies the queued row-group plan and selections;
+    /// see
     /// [`RemainingRowGroups::peek_next_row_group`].
     fn peek_next_row_group(&self) -> Result<Option<usize>, ParquetError> {
         Ok(self
@@ -111,8 +229,7 @@ impl RowGroupFrontier {
     }
 
     fn clear_remaining(&mut self) {
-        self.selection = None;
-        self.row_groups.clear();
+        self.queued.clear();
     }
 
     /// Plan whether a selected row group should be read or skipped.
@@ -142,35 +259,30 @@ impl RowGroupFrontier {
     /// Advance queued row groups until one should be handed to the builder.
     fn next_readable_row_group(&mut self) -> Result<Option<NextRowGroup>, 
ParquetError> {
         loop {
-            let Some(&row_group_idx) = self.row_groups.front() else {
+            let Some(row_group_idx) = self.queued.front() else {
                 return Ok(None);
             };
-            if self.budget.is_exhausted()
-                || self
-                    .selection
-                    .as_ref()
-                    .is_some_and(|selection| selection.row_count() == 0)
-            {
+            // A global selection can be exhausted before its row-group queue.
+            // Per-row-group selections have no shared cursor to exhaust; empty
+            // local selections are discarded by the `selected_rows == 0` path 
below.
+            if self.budget.is_exhausted() || 
self.queued.global_selection_is_exhausted() {
                 self.clear_remaining();
                 return Ok(None);
             }
 
-            let row_count = self.row_group_num_rows(row_group_idx)?;
-            let (selection, selected_rows) = match self.selection.as_mut() {
+            let row_count = 
self.parquet_metadata.row_group_num_rows(row_group_idx)?;
+            let selection = self.queued.pop_front_selection(row_count);
+            let (selection, selected_rows) = match selection {
                 Some(selection) => {
-                    let selection = selection.split_off(row_count);
                     let selected_rows = selection.row_count();
                     if selected_rows == 0 {
-                        self.row_groups.pop_front();
                         continue;
                     }
-
-                    let selection = if selected_rows == row_count {
-                        None
-                    } else {
-                        Some(selection)
-                    };
-                    (selection, selected_rows)
+                    // An all-rows selection is equivalent to no selection
+                    (
+                        (selected_rows != row_count).then_some(selection),
+                        selected_rows,
+                    )
                 }
                 None => (None, row_count),
             };
@@ -184,11 +296,9 @@ impl RowGroupFrontier {
 
             match self.plan_selected_row_group(next_row_group, selected_rows) {
                 QueuedRowGroupDecision::Read(next_row_group) => {
-                    self.row_groups.pop_front();
                     return Ok(Some(next_row_group));
                 }
                 QueuedRowGroupDecision::Skip { remaining_budget } => {
-                    self.row_groups.pop_front();
                     self.budget = remaining_budget;
                 }
             }
@@ -224,10 +334,8 @@ pub(crate) struct RemainingRowGroupsParts {
     pub schema: SchemaRef,
     /// The Parquet file metadata.
     pub metadata: Arc<ParquetMetaData>,
-    /// Row groups not yet handed to the reader builder.
-    pub row_groups: Vec<usize>,
-    /// The not-yet-consumed slice of the global row selection.
-    pub selection: Option<RowSelection>,
+    /// Row groups and selections not yet handed to the reader builder.
+    pub row_group_plan: RowGroupPlan,
     /// Offset still to be skipped before the next readable row group.
     pub offset: Option<usize>,
     /// Output rows still permitted across the remaining row groups.
@@ -240,23 +348,21 @@ impl RemainingRowGroups {
     pub fn new(
         schema: SchemaRef,
         parquet_metadata: Arc<ParquetMetaData>,
-        row_groups: Vec<usize>,
-        selection: Option<RowSelection>,
+        row_group_plan: RowGroupPlan,
         budget: RowBudget,
         has_predicates: bool,
         row_group_reader_builder: RowGroupReaderBuilder,
-    ) -> Self {
-        Self {
+    ) -> Result<Self, ParquetError> {
+        Ok(Self {
             schema,
             frontier: RowGroupFrontier::new(
                 parquet_metadata,
-                row_groups,
-                selection,
+                row_group_plan,
                 budget,
                 has_predicates,
-            ),
+            )?,
             row_group_reader_builder,
-        }
+        })
     }
 
     /// Decompose into [`RemainingRowGroupsParts`].
@@ -273,16 +379,15 @@ impl RemainingRowGroups {
         // `has_predicates` is recomputed by `build()` from the filter.
         let RowGroupFrontier {
             parquet_metadata,
-            row_groups,
-            selection,
+            queued,
             budget,
             has_predicates: _,
         } = frontier;
+        let row_group_plan = queued.into_plan();
         RemainingRowGroupsParts {
             schema,
             metadata: parquet_metadata,
-            row_groups: Vec::from(row_groups),
-            selection,
+            row_group_plan,
             offset: budget.offset(),
             limit: budget.limit(),
             reader_builder: row_group_reader_builder.into_parts(),
@@ -317,7 +422,7 @@ impl RemainingRowGroups {
     /// Number of row groups remaining (not including the one currently
     /// being decoded).
     pub fn row_groups_remaining(&self) -> usize {
-        self.frontier.row_groups.len()
+        self.frontier.queued.len()
     }
 
     /// Peek at the file-level row-group index that the next call to
@@ -330,11 +435,10 @@ impl RemainingRowGroups {
     /// when no row groups remain, or when every remaining row group
     /// would be skipped under the current selection/budget.
     ///
-    /// Cost: one clone of the queued row-group indices and optional
-    /// row-selection per call (the frontier is cloned so the real advance
-    /// logic can run non-destructively). For callers that peek once per
-    /// row-group boundary this is O(remaining row groups + selectors) per
-    /// boundary.
+    /// Cost: one clone of the queued row-group plan and selections per call
+    /// (the frontier is cloned so the real advance logic can run
+    /// non-destructively). For callers that peek once per row-group boundary
+    /// this is O(remaining row groups + selectors) per boundary.
     pub fn peek_next_row_group(&self) -> Result<Option<usize>, ParquetError> {
         if self.row_group_reader_builder.has_active_row_group() {
             return Ok(None);
@@ -394,3 +498,178 @@ impl RemainingRowGroups {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::arrow::arrow_reader::RowSelector;
+    use crate::arrow::push_decoder::test::test_file_parquet_metadata;
+
+    fn global_plan(
+        row_groups: Option<Vec<usize>>,
+        selection: Option<RowSelection>,
+    ) -> RowGroupPlan {
+        RowGroupPlan::Global {
+            row_groups,
+            selection,
+        }
+    }
+
+    #[test]
+    fn queued_row_groups_encapsulates_plan_transitions() {
+        let metadata = test_file_parquet_metadata();
+
+        let mut all_row_groups =
+            QueuedRowGroups::try_new(&metadata, global_plan(None, 
None)).unwrap();
+        assert_eq!(all_row_groups.len(), 2);
+        assert_eq!(all_row_groups.front(), Some(0));
+        assert!(!all_row_groups.global_selection_is_exhausted());
+        assert!(all_row_groups.pop_front_selection(200).is_none());
+        assert_eq!(all_row_groups.front(), Some(1));
+        all_row_groups.clear();
+        assert_eq!(all_row_groups.len(), 0);
+        assert!(matches!(
+            all_row_groups.into_plan(),
+            RowGroupPlan::Global {
+                row_groups: Some(row_groups),
+                selection: None,
+            } if row_groups.is_empty()
+        ));
+
+        let global_selection = RowSelection::from(vec![
+            RowSelector::skip(10),
+            RowSelector::select(5),
+            RowSelector::skip(185),
+            RowSelector::select(200),
+        ]);
+        let mut global = QueuedRowGroups::try_new(
+            &metadata,
+            global_plan(Some(vec![0, 1]), Some(global_selection)),
+        )
+        .unwrap();
+        let first = global.pop_front_selection(200).unwrap();
+        assert_eq!(first.row_count(), 5);
+        assert!(!global.global_selection_is_exhausted());
+        assert!(matches!(
+            global.into_plan(),
+            RowGroupPlan::Global {
+                row_groups: Some(row_groups),
+                selection: Some(selection),
+            } if row_groups == vec![1] && selection.row_count() == 200
+        ));
+
+        let local_selection =
+            RowSelection::from(vec![RowSelector::skip(5), 
RowSelector::select(3)]);
+        let mut local = QueuedRowGroups::try_new(
+            &metadata,
+            RowGroupPlan::PerRowGroup(vec![
+                RowGroupSelection::new(1, Some(local_selection)),
+                RowGroupSelection::new(0, None),
+            ]),
+        )
+        .unwrap();
+        assert_eq!(local.front(), Some(1));
+        assert_eq!(local.pop_front_selection(200).unwrap().row_count(), 3);
+        assert!(!local.global_selection_is_exhausted());
+        assert!(matches!(
+            local.into_plan(),
+            RowGroupPlan::PerRowGroup(row_groups)
+                if row_groups == vec![RowGroupSelection::new(0, None)]
+        ));
+
+        let exhausted = QueuedRowGroups::try_new(
+            &metadata,
+            global_plan(
+                Some(vec![0]),
+                Some(RowSelection::from(vec![RowSelector::skip(200)])),
+            ),
+        )
+        .unwrap();
+        assert!(exhausted.global_selection_is_exhausted());
+    }
+
+    #[test]
+    fn frontier_handles_global_and_local_exhaustion() {
+        let metadata = test_file_parquet_metadata();
+        let budget = RowBudget::new(None, None);
+
+        let mut global = RowGroupFrontier::new(
+            Arc::clone(&metadata),
+            global_plan(
+                Some(vec![0, 1]),
+                Some(RowSelection::from(vec![RowSelector::skip(400)])),
+            ),
+            budget,
+            false,
+        )
+        .unwrap();
+        assert!(global.next_readable_row_group().unwrap().is_none());
+        assert_eq!(global.queued.len(), 0);
+
+        let mut local = RowGroupFrontier::new(
+            Arc::clone(&metadata),
+            RowGroupPlan::PerRowGroup(vec![
+                RowGroupSelection::new(0, 
Some(RowSelection::from(vec![RowSelector::skip(200)]))),
+                RowGroupSelection::new(1, None),
+            ]),
+            budget,
+            false,
+        )
+        .unwrap();
+        let next = local.next_readable_row_group().unwrap().unwrap();
+        assert_eq!(next.row_group_idx, 1);
+        assert_eq!(next.row_count, 200);
+        assert!(next.selection.is_none());
+
+        let mut exhausted_budget = RowGroupFrontier::new(
+            metadata,
+            RowGroupPlan::PerRowGroup(vec![RowGroupSelection::new(0, None)]),
+            RowBudget::new(None, Some(0)),
+            false,
+        )
+        .unwrap();
+        assert!(
+            exhausted_budget
+                .next_readable_row_group()
+                .unwrap()
+                .is_none()
+        );
+        assert_eq!(exhausted_budget.queued.len(), 0);
+    }
+
+    #[test]
+    fn frontier_reports_invalid_global_row_group_while_peeking() {
+        let metadata = test_file_parquet_metadata();
+        let frontier = RowGroupFrontier::new(
+            metadata,
+            global_plan(Some(vec![2]), None),
+            RowBudget::new(None, None),
+            false,
+        )
+        .unwrap();
+
+        let error = frontier.peek_next_row_group().unwrap_err();
+        assert!(
+            error
+                .to_string()
+                .contains("Row group index 2 out of bounds for file with 2 row 
groups")
+        );
+    }
+
+    #[test]
+    fn metadata_row_count_overflow_is_reported() {
+        let metadata = test_file_parquet_metadata();
+        let mut builder = metadata.as_ref().clone().into_builder();
+        let mut row_groups = builder.take_row_groups();
+        let negative_row_group = row_groups
+            .remove(0)
+            .into_builder()
+            .set_num_rows(-1)
+            .build()
+            .unwrap();
+        let metadata = 
builder.set_row_groups(vec![negative_row_group]).build();
+
+        let error = metadata.row_group_num_rows(0).unwrap_err();
+        assert!(error.to_string().contains("Row count overflow"));
+    }
+}
diff --git a/parquet/src/file/metadata/mod.rs b/parquet/src/file/metadata/mod.rs
index 15a16829ba..e7f7199177 100644
--- a/parquet/src/file/metadata/mod.rs
+++ b/parquet/src/file/metadata/mod.rs
@@ -496,6 +496,24 @@ impl ParquetMetaData {
         &self.row_groups
     }
 
+    /// Returns the number of rows in `row_group_idx`.
+    ///
+    /// Returns an error if the row group index is out of bounds or its row
+    /// count cannot be represented as a [`usize`].
+    pub fn row_group_num_rows(&self, row_group_idx: usize) -> Result<usize> {
+        self.row_groups
+            .get(row_group_idx)
+            .ok_or_else(|| {
+                ParquetError::General(format!(
+                    "Row group index {row_group_idx} out of bounds for file 
with {} row groups",
+                    self.num_row_groups()
+                ))
+            })?
+            .num_rows()
+            .try_into()
+            .map_err(|e| ParquetError::General(format!("Row count overflow: 
{e}")))
+    }
+
     /// Returns the page index for this file if loaded
     ///
     /// Returns `None` if the parquet file lacks page indexes or

Reply via email to