adriangb commented on code in PR #11157:
URL: https://github.com/apache/arrow-rs/pull/11157#discussion_r4078078049


##########
parquet/src/arrow/arrow_reader/mod.rs:
##########
@@ -774,6 +776,32 @@ impl ArrowReaderOptions {
         self
     }
 
+    /// Sets the [`ColumnChunkMask`] for the Parquet [ColumnIndex] structure.
+    ///
+    /// The column index can be costly to decode and store, especially when it 
is needed
+    /// only for a subset of row groups or columns (such as when filtering by 
a predicate
+    /// on a single column). Providing a [`ColumnChunkMask`] can greatly 
decrease
+    /// the time needed to decode this metadata.
+    ///
+    /// [ColumnIndex]: 
https://github.com/apache/parquet-format/blob/master/PageIndex.md
+    pub fn with_column_index_mask(mut self, mask: ColumnChunkMask) -> Self {
+        self.column_index_mask = mask;
+        self
+    }
+
+    /// Sets the [`ColumnChunkMask`] for the Parquet [OffsetIndex] structure.
+    ///
+    /// The offset index can be costly to decode and store, especially when it 
is needed
+    /// only for a subset of row groups or columns (such as when projecting a 
small subset
+    /// of columns). Providing a [`ColumnChunkMask`] can greatly decrease
+    /// the time needed to decode this metadata.
+    ///
+    /// [OffsetIndex]: 
https://github.com/apache/parquet-format/blob/master/PageIndex.md
+    pub fn with_offset_index_mask(mut self, mask: ColumnChunkMask) -> Self {
+        self.offset_index_mask = mask;
+        self
+    }

Review Comment:
   **Docs / Bug (C3).** These docs suggest a column index mask for the 
predicate columns and an offset index mask for the projected columns. With that 
setup, `StatisticsConverter` returns arrays of different lengths (see the 
review body):
   
   ```text
   CI columns([0]) (predicate), OI columns([1]) (projection): data_page_mins 
len 30, data_page_row_counts len 0
   ```
   
   Page pruning needs the offset index of the predicate columns too. The 
suggestion also adds two other traps (C7): a mask without a policy does 
nothing, and `ArrowReaderMetadata::try_new` ignores the masks.
   
   ```suggestion
       /// Sets the [`ColumnChunkMask`] for the Parquet [ColumnIndex] structure.
       ///
       /// The column index can be costly to decode and store, especially when 
it is needed
       /// only for a subset of row groups or columns (such as when filtering 
by a predicate
       /// on a single column). Providing a [`ColumnChunkMask`] can greatly 
decrease
       /// the time needed to decode this metadata.
       ///
       /// The mask applies only if the column index policy is not 
[`PageIndexPolicy::Skip`]
       /// (the default), and only when the page index is loaded with these 
options (for
       /// example by [`ArrowReaderMetadata::load`]). 
[`ArrowReaderMetadata::try_new`] ignores it.
       ///
       /// [ColumnIndex]: 
https://github.com/apache/parquet-format/blob/master/PageIndex.md
       pub fn with_column_index_mask(mut self, mask: ColumnChunkMask) -> Self {
           self.column_index_mask = mask;
           self
       }
   
       /// Sets the [`ColumnChunkMask`] for the Parquet [OffsetIndex] structure.
       ///
       /// The offset index can be costly to decode and store, especially when 
it is needed
       /// only for a subset of row groups or columns (such as when projecting 
a small subset
       /// of columns). Providing a [`ColumnChunkMask`] can greatly decrease
       /// the time needed to decode this metadata.
       ///
       /// Page pruning with the column index also needs the offset index of 
the same
       /// columns. So include the predicate columns in this mask, not only the 
projected
       /// columns. The same notes as for [`Self::with_column_index_mask`] 
apply.
       ///
       /// [OffsetIndex]: 
https://github.com/apache/parquet-format/blob/master/PageIndex.md
       pub fn with_offset_index_mask(mut self, mask: ColumnChunkMask) -> Self {
           self.offset_index_mask = mask;
           self
       }
   ```



##########
parquet/src/file/metadata/reader.rs:
##########
@@ -488,6 +619,8 @@ impl ParquetMetaDataReader {
         let push_decoder = 
ParquetMetaDataPushDecoder::try_new_with_metadata(file_size, metadata)?
             .with_offset_index_policy(self.offset_index)
             .with_column_index_policy(self.column_index)
+            .with_offset_index_mask(self.offset_index_mask.clone())
+            .with_column_index_mask(self.column_index_mask.clone())

Review Comment:
   **Bug (C1).** With a prefetch hint and a mask, this call returns the page 
index of another column chunk, with no error (also with `Required`). Without a 
mask, the same call fails. Details, a diagram and a one-line fix are in the 
review body.
   
   ```text
   CI mask rg 0 x col 1, prefetch = file_len - 27:  c1 page min/max = [2]/[11]  
 (true: [1]/[10], these are c2's stats)
   OI mask rg 0 x col 1, prefetch = file_len - 11:  c1 page offset  = 232       
 (true: 145, 232 is c2's data_page_offset)
   same prefetch, no mask:                          Err("Corrupted parquet 
file: index data range (1372..1503) exceeds remainder length (1492)")
   ```
   
   Cause: `load_metadata_via_suffix` (L826) returns the remainder with start 
`0`, but its real file offset is `file_len - suffix_len`. The mask makes the 
covering range short enough that the wrong slice is in bounds.



##########
parquet/src/file/metadata/reader.rs:
##########
@@ -104,6 +107,114 @@ impl From<bool> for PageIndexPolicy {
     }
 }
 
+/// Struct to specify column chunks for which metadata is required.
+///
+/// Column chunks are identified by row group index and column index. This 
struct
+/// allows for specifying vertical slices of column chunk data (via 
[`Self::columns`]),
+/// horizontal slices (via [`Self::row_groups`]), or the intersection of the 
two
+/// (via [`Self::row_groups_and_columns`]).
+///
+/// At present this is only used to select elements of the [Page Index] for 
decoding.
+///
+/// # Examples
+///
+/// To select columns 0 and 1 from all row groups:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::columns([0, 1]);
+/// ```
+///
+/// To select all columns from row group 2:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::row_groups([2]);
+/// ```
+///
+/// To select columns 1 and 3 from row group 0:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::row_groups_and_columns([0], [1, 3]);
+/// ```
+///
+/// [Page Index]: https://parquet.apache.org/docs/file-format/pageindex/
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct ColumnChunkMask {
+    // using i32 because that's how thrift vectors are sized
+    row_groups: Option<Arc<BTreeSet<i32>>>,
+    columns: Option<Arc<BTreeSet<i32>>>,
+}
+
+impl ColumnChunkMask {
+    /// Select all row groups and columns.
+    pub fn all() -> Self {
+        Self::default()
+    }
+
+    /// Select only the listed columns.
+    ///
+    /// Any indices in `columns` that are less than zero will be ignored. 
Passing an empty
+    /// set is treated the same as selecting all columns.
+    pub fn columns(columns: impl IntoIterator<Item = i32>) -> Self {

Review Comment:
   **API, one-way door (C5).** The constructors take `i32`. The other APIs 
around this one use `usize`: `includes_*` here, `ProjectionMask::leaves`, 
`with_row_groups`, `PageIndexProvider::column_index`, 
`ParquetStatisticsPolicy::skip_except`. So every caller writes `.map(|i| i as 
i32)`. That cast wraps silently, and a negative result then means "ignore" 
(and, with C4, "all"). A related detail: 
`ColumnChunkMask::all().includes_column(u32::MAX as usize)` is `false`.
   
   Suggestion: take `impl IntoIterator<Item = usize>` in the public API. Keep 
`i32`/`u32` storage internal if you want it. A change after release breaks 
every caller, so this is the time to decide.



##########
parquet/src/file/metadata/reader.rs:
##########
@@ -104,6 +107,114 @@ impl From<bool> for PageIndexPolicy {
     }
 }
 
+/// Struct to specify column chunks for which metadata is required.
+///
+/// Column chunks are identified by row group index and column index. This 
struct
+/// allows for specifying vertical slices of column chunk data (via 
[`Self::columns`]),
+/// horizontal slices (via [`Self::row_groups`]), or the intersection of the 
two
+/// (via [`Self::row_groups_and_columns`]).

Review Comment:
   **Docs (C6).** The indexes are leaf (Parquet column) indexes, not Arrow 
field indexes. For schema `a, s: {x, y}, b`, `columns([2])` selects `s.y`, not 
`b`. A caller that passes a root field index loads the index of another column, 
with no error.
   
   ```suggestion
   /// Column chunks are identified by row group index and leaf column index 
(the index of the
   /// column in [`SchemaDescriptor::columns`], not the index of a root or 
Arrow field). This struct
   /// allows for specifying vertical slices of column chunk data (via 
[`Self::columns`]),
   /// horizontal slices (via [`Self::row_groups`]), or the intersection of the 
two
   /// (via [`Self::row_groups_and_columns`]).
   ```



##########
parquet/src/file/metadata/push_decoder.rs:
##########
@@ -481,25 +514,81 @@ enum DecodeState {
     Intermediate,
 }
 
-/// Returns the byte range needed to read the offset/page indexes, based on the
-/// specified policies
+/// Returns the minimum set of non-overlapping ranges needed to cover the 
requested
+/// offset and column indexes.
+///
+/// If no page indexes are present in the file, or none are actually
+/// requested by the policies passed in, this will return an empty
+/// vector.
+///
+/// This may result in more ranges to fetch but may also result in fewer bytes
+/// being read (and stored). To get a single range for all indexes, sort
+/// the resultant vector by the range starts, and then create a single range
+/// using `start` from the head and `end` from the tail.
 ///
-/// Returns None if no page indexes are needed
-pub fn range_for_page_index(
+/// ```ignore
+/// # use core::ops::Range;
+/// # fn coalesce_page_index_ranges(ranges: &mut Vec<Range<u64>>) -> 
Option<Range<u64>> {
+///     ranges.sort_by_key(|r| r.start);
+///     let range = (ranges.first()?.start..ranges.last()?.end);
+///     Some(range)
+/// # }
+/// ```
+pub fn ranges_for_page_index(
     metadata: &ParquetMetaData,
     column_index_policy: PageIndexPolicy,
     offset_index_policy: PageIndexPolicy,
-) -> Option<Range<u64>> {
-    let mut range = None;
-    for c in metadata.row_groups().iter().flat_map(|r| r.columns()) {
-        if column_index_policy != PageIndexPolicy::Skip {
-            range = acc_range(range, c.column_index_range());
-        }
-        if offset_index_policy != PageIndexPolicy::Skip {
-            range = acc_range(range, c.offset_index_range());
+    column_index_mask: &ColumnChunkMask,
+    offset_index_mask: &ColumnChunkMask,
+) -> Vec<Range<u64>> {
+    let mut result = Vec::new();
+
+    fn add_ranges<T>(
+        metadata: &ParquetMetaData,
+        mask: &ColumnChunkMask,
+        ranges: &mut Vec<Range<u64>>,
+        f: T,
+    ) where
+        T: Fn(&ColumnChunkMetaData) -> Option<Range<u64>>,
+    {
+        for (rg_idx, rg) in metadata.row_groups().iter().enumerate() {
+            if mask.includes_row_group(rg_idx) {
+                for (col_idx, col) in rg.columns().iter().enumerate() {
+                    if mask.includes_column(col_idx)
+                        && let Some(range) = f(col)
+                    {
+                        // ranges shouldn't overlap, so only check for 
contiguous ranges
+                        // [s1..e1], [s2..e2] where e1 == s2
+                        if let Some(last) = ranges.last_mut()
+                            && last.end == range.start
+                        {
+                            last.end = range.end;
+                        } else {
+                            ranges.push(range);
+                        }

Review Comment:
   **Perf / Robustness (C17, C8).** This merges a range only if it starts 
exactly where the last emitted range ends. So:
   
   - Ranges that are adjacent in the file, but not in emission order (CI rg x 
col, then OI rg x col), stay separate.
   - Duplicate or overlapping ranges stay separate. A crafted footer where 4 
column indexes point at the same range makes the caller fetch that range 4 
times: `requested 7 ranges, 46402 bytes total, file is 12460 bytes: [4..11582, 
4..11582, 4..11582, 4..11582, 11446..11468, 11490..11534, 11558..11582]`. The 
sync reader fetches the covering range once.
   
   The doc comment (L517) says "minimum set of non-overlapping ranges", which 
is not true in these cases. Fix: push every range, then sort and merge in place 
(no extra allocation). With this change the crafted file gives `[4..11582]`, 
and the existing tests pass (4 ranges for `columns([0])`, 1 range for `all()`):
   
   ```diff
   -                        // ranges shouldn't overlap, so only check for 
contiguous ranges
   -                        // [s1..e1], [s2..e2] where e1 == s2
   -                        if let Some(last) = ranges.last_mut()
   -                            && last.end == range.start
   -                        {
   -                            last.end = range.end;
   -                        } else {
   -                            ranges.push(range);
   -                        }
   +                        ranges.push(range);
    ...
   +    // merge overlapping and adjacent ranges, whatever the emission order
   +    result.sort_unstable_by_key(|r| r.start);
   +    result.dedup_by(|next, prev| {
   +        let merge = next.start <= prev.end;
   +        if merge {
   +            prev.end = prev.end.max(next.end);
   +        }
   +        merge
   +    });
        result
   ```



##########
parquet/src/file/metadata/reader.rs:
##########
@@ -1392,4 +1525,32 @@ mod async_tests {
         read_and_check(f.as_file(), PageIndexPolicy::Optional).unwrap();
         read_and_check(f.as_file(), PageIndexPolicy::Skip).unwrap();
     }
+
+    #[test]
+    fn test_chunk_mask() {

Review Comment:
   **Test gap (N1).** `test_chunk_mask` is in `mod async_tests`, which has 
`#[cfg(all(feature = "async", feature = "arrow", test))]`. The default features 
of `parquet` do not include `async`, so `cargo test -p parquet` does not run 
it. The test uses no async code. Move it to `mod tests` (L977).



##########
parquet/tests/arrow_reader/page_index.rs:
##########
@@ -0,0 +1,236 @@
+// 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.
+
+//! Tests of selective page index population
+
+use parquet::file::metadata::{ColumnChunkMask, PageIndexPolicy, 
ParquetMetaDataReader};
+
+use crate::custom_page_index_provider::create_test_file;
+
+#[test]
+fn test_parse_selected_columns() {
+    // test populating PageIndex with a subset of columns
+    let temp_file = create_test_file();
+
+    // populate column 0 for column index and columns 0 & 2 for the offset 
index
+    let mut reader = ParquetMetaDataReader::new()
+        .with_page_index_policy(PageIndexPolicy::Optional)
+        .with_column_index_mask(ColumnChunkMask::columns([0]))
+        .with_offset_index_mask(ColumnChunkMask::columns([0, 2]));
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    let num_rg = metadata.num_row_groups();
+
+    // test page indexes
+    for rg in 0..num_rg {
+        let idx = metadata.page_index_for_row_group(rg);
+        // column 0 has both indexes
+        assert!(idx.column_index(0).is_some());
+        assert!(idx.offset_index(0).is_some());
+        // column 1 has no indexes
+        assert!(idx.column_index(1).is_none());
+        assert!(idx.offset_index(1).is_none());
+        // column 2 has offset index
+        assert!(idx.column_index(2).is_none());
+        assert!(idx.offset_index(2).is_some());
+        // column 3 has no indexes
+        assert!(idx.column_index(3).is_none());
+        assert!(idx.offset_index(3).is_none());
+    }
+}
+
+#[test]
+fn test_parse_selected_columns_mixed() {
+    // test populating PageIndex with a subset of columns
+    let temp_file = create_test_file();
+
+    // populate column 0 for column index and all columns for offset index
+    let mut reader = ParquetMetaDataReader::new()
+        .with_page_index_policy(PageIndexPolicy::Optional)
+        .with_column_index_mask(ColumnChunkMask::columns([0]))
+        .with_offset_index_mask(ColumnChunkMask::all());
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    let num_rg = metadata.num_row_groups();
+
+    // test page indexes
+    for rg in 0..num_rg {
+        let idx = metadata.page_index_for_row_group(rg);
+        // column 0 has both indexes
+        assert!(idx.column_index(0).is_some());
+        assert!(idx.offset_index(0).is_some());
+        // column 1 has no indexes
+        assert!(idx.column_index(1).is_none());
+        assert!(idx.offset_index(1).is_some());
+        // column 2 has offset index
+        assert!(idx.column_index(2).is_none());
+        assert!(idx.offset_index(2).is_some());
+        // column 3 has no indexes
+        assert!(idx.column_index(3).is_none());
+        assert!(idx.offset_index(3).is_some());
+    }
+}
+
+#[test]
+fn test_parse_selected_row_groups() {
+    // test populating PageIndex with a subset of row groups
+    let temp_file = create_test_file();
+
+    // populate indexes for row groups 0 and 2
+    let mut reader = ParquetMetaDataReader::new()
+        .with_page_index_policy(PageIndexPolicy::Optional)
+        .with_column_index_mask(ColumnChunkMask::row_groups([0, 2]))
+        .with_offset_index_mask(ColumnChunkMask::row_groups([0, 2]));
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    let num_cols = metadata.file_metadata().schema_descr().num_columns();
+    assert!(metadata.page_index().is_some());
+    let page_index = metadata.page_index().unwrap();
+
+    for col in 0..num_cols {
+        // row group 0 has both indexes
+        assert!(page_index.column_index(0, col).is_some());
+        assert!(page_index.offset_index(0, col).is_some());
+        // row group 1 has no indexes
+        assert!(page_index.column_index(1, col).is_none());
+        assert!(page_index.offset_index(1, col).is_none());
+        // row group 2 has both indexes
+        assert!(page_index.column_index(2, col).is_some());
+        assert!(page_index.offset_index(2, col).is_some());
+    }
+}
+
+#[test]
+fn test_parse_selected_row_groups_and_columns() {
+    // test populating PageIndex by row group and column
+    let temp_file = create_test_file();
+
+    // populate only row group 1, column index gets column 0, offset index gets
+    // columns 0 and 2.
+    let mut reader = ParquetMetaDataReader::new()
+        .with_page_index_policy(PageIndexPolicy::Optional)
+        .with_column_index_mask(ColumnChunkMask::row_groups_and_columns([1], 
[0]))
+        .with_offset_index_mask(ColumnChunkMask::row_groups_and_columns([1], 
[0, 2]));
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    assert!(metadata.page_index().is_some());
+    let num_cols = metadata.file_metadata().schema_descr().num_columns();
+
+    // row groups 0 and 2 should have no indexes
+    [0, 2].into_iter().for_each(|i| {
+        let rg_idx = metadata.page_index_for_row_group(i);
+        for col in 0..num_cols {
+            assert!(rg_idx.column_index(col).is_none());
+            assert!(rg_idx.offset_index(col).is_none());
+        }
+    });
+
+    // row group 1 should have column index for column 0 and offset index for 
columns 0 & 2
+    let rg_idx = metadata.page_index_for_row_group(1);
+    assert!(rg_idx.column_index(0).is_some());
+    assert!(rg_idx.offset_index(0).is_some());
+    assert!(rg_idx.column_index(1).is_none());
+    assert!(rg_idx.offset_index(1).is_none());
+    assert!(rg_idx.column_index(2).is_none());
+    assert!(rg_idx.offset_index(2).is_some());
+    assert!(rg_idx.column_index(3).is_none());
+    assert!(rg_idx.offset_index(3).is_none());
+}
+
+#[test]
+fn test_page_index_sizes() {
+    // test populating PageIndex by row group and column
+    let temp_file = create_test_file();
+
+    // no index
+    let mut reader = 
ParquetMetaDataReader::new().with_page_index_policy(PageIndexPolicy::Skip);
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    assert!(metadata.page_index().is_none());
+    #[cfg(not(feature = "encryption"))]
+    assert_eq!(metadata.memory_size(), 7393);
+    #[cfg(feature = "encryption")]
+    assert_eq!(metadata.memory_size(), 7817);
+
+    // full index
+    let mut reader = 
ParquetMetaDataReader::new().with_page_index_policy(PageIndexPolicy::Required);
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    assert!(metadata.page_index().is_some());
+    #[cfg(not(feature = "encryption"))]
+    assert_eq!(metadata.memory_size(), 13897);
+    #[cfg(feature = "encryption")]
+    assert_eq!(metadata.memory_size(), 14321);
+
+    // populate column 0 for column index and all columns for offset index
+    let mut reader = ParquetMetaDataReader::new()
+        .with_page_index_policy(PageIndexPolicy::Optional)
+        .with_column_index_mask(ColumnChunkMask::columns([0]))
+        .with_offset_index_mask(ColumnChunkMask::all());
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    assert!(metadata.page_index().is_some());
+    #[cfg(not(feature = "encryption"))]
+    assert_eq!(metadata.memory_size(), 12776);
+    #[cfg(feature = "encryption")]
+    assert_eq!(metadata.memory_size(), 13200);
+
+    // populate column 0 for column index and columns 0 & 2 for the offset 
index
+    let mut reader = ParquetMetaDataReader::new()
+        .with_page_index_policy(PageIndexPolicy::Optional)
+        .with_column_index_mask(ColumnChunkMask::columns([0]))
+        .with_offset_index_mask(ColumnChunkMask::columns([0, 2]));
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    assert!(metadata.page_index().is_some());
+    #[cfg(not(feature = "encryption"))]
+    assert_eq!(metadata.memory_size(), 12056);
+    #[cfg(feature = "encryption")]
+    assert_eq!(metadata.memory_size(), 12480);
+
+    // populate only row group 1, column index gets column 0, offset index gets
+    // columns 0 and 2.
+    let mut reader = ParquetMetaDataReader::new()
+        .with_page_index_policy(PageIndexPolicy::Optional)
+        .with_column_index_mask(ColumnChunkMask::row_groups_and_columns([1], 
[0]))
+        .with_offset_index_mask(ColumnChunkMask::row_groups_and_columns([1], 
[0, 2]));
+
+    // parse metadata
+    reader.try_parse(&temp_file).unwrap();
+    let metadata = reader.finish().unwrap();
+    assert!(metadata.page_index().is_some());
+    #[cfg(not(feature = "encryption"))]
+    assert_eq!(metadata.memory_size(), 11326);
+    #[cfg(feature = "encryption")]
+    assert_eq!(metadata.memory_size(), 11750);
+}

Review Comment:
   **Test gap (C14).** No test sets a mask through `ArrowReaderOptions` or 
`ParquetMetaDataReader::with_page_index_mask`. Mutation testing (on the 
[#11159](https://github.com/apache/arrow-rs/pull/11159) head, same code) shows 
it: `ArrowReaderOptions::with_column_index_mask`, `with_offset_index_mask`, 
both getters, and `ParquetMetaDataReader::with_page_index_mask` can each return 
`Default::default()`, and all tests still pass. If a refactor drops the mask 
plumbing, readers decode the full index again and no test fails. There, these 
two tests kill all 5 mutants. They pass on this head.
   
   Also missing: a data read (push decoder or async) with a mask. That test 
would have found C2. Add it after the C2 fix.
   
   ```suggestion
   }
   
   /// Asserts which cells of the page index are populated
   fn assert_page_index_cells(
       metadata: &parquet::file::metadata::ParquetMetaData,
       expect_ci: impl Fn(usize, usize) -> bool,
       expect_oi: impl Fn(usize, usize) -> bool,
   ) {
       let page_index = metadata.page_index().expect("page index should be 
loaded");
       let num_cols = metadata.file_metadata().schema_descr().num_columns();
       for rg in 0..metadata.num_row_groups() {
           for col in 0..num_cols {
               let ci = page_index.column_index(rg, col).is_some();
               let oi = page_index.offset_index(rg, col).is_some();
               assert_eq!(ci, expect_ci(rg, col), "column index rg={rg} 
col={col}");
               assert_eq!(oi, expect_oi(rg, col), "offset index rg={rg} 
col={col}");
           }
       }
   }
   
   #[test]
   fn test_arrow_reader_options_page_index_masks() {
       use parquet::arrow::arrow_reader::{ArrowReaderMetadata, 
ArrowReaderOptions};
       let file = create_test_file();
       let column_mask = ColumnChunkMask::row_groups_and_columns([1], [0]);
       let offset_mask = ColumnChunkMask::columns([2]);
       let options = ArrowReaderOptions::new()
           .with_page_index_policy(PageIndexPolicy::Required)
           .with_column_index_mask(column_mask.clone())
           .with_offset_index_mask(offset_mask.clone());
       assert_eq!(options.column_index_mask(), &column_mask);
       assert_eq!(options.offset_index_mask(), &offset_mask);
       let expect_ci = |rg: usize, col: usize| rg == 1 && col == 0;
       let expect_oi = |_rg: usize, col: usize| col == 2;
   
       // sync reader builders
       let arrow_metadata = ArrowReaderMetadata::load(&file, 
options.clone()).unwrap();
       assert_page_index_cells(arrow_metadata.metadata(), expect_ci, expect_oi);
   
       // `AsyncFileReader::get_metadata` implementations
       let metadata = ParquetMetaDataReader::new()
           .with_arrow_reader_options(Some(&options))
           .parse_and_finish(&file)
           .unwrap();
       assert_page_index_cells(&metadata, expect_ci, expect_oi);
   }
   
   #[test]
   fn test_parse_with_page_index_mask() {
       let file = create_test_file();
       let metadata = ParquetMetaDataReader::new()
           .with_page_index_policy(PageIndexPolicy::Required)
           .with_page_index_mask(ColumnChunkMask::row_groups_and_columns([2], 
[1, 3]))
           .parse_and_finish(&file)
           .unwrap();
       let expect = |rg: usize, col: usize| rg == 2 && (col == 1 || col == 3);
       assert_page_index_cells(&metadata, expect, expect);
   }
   ```



##########
parquet/src/file/metadata/reader.rs:
##########
@@ -104,6 +107,114 @@ impl From<bool> for PageIndexPolicy {
     }
 }
 
+/// Struct to specify column chunks for which metadata is required.
+///
+/// Column chunks are identified by row group index and column index. This 
struct
+/// allows for specifying vertical slices of column chunk data (via 
[`Self::columns`]),
+/// horizontal slices (via [`Self::row_groups`]), or the intersection of the 
two
+/// (via [`Self::row_groups_and_columns`]).
+///
+/// At present this is only used to select elements of the [Page Index] for 
decoding.
+///
+/// # Examples
+///
+/// To select columns 0 and 1 from all row groups:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::columns([0, 1]);
+/// ```
+///
+/// To select all columns from row group 2:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::row_groups([2]);
+/// ```
+///
+/// To select columns 1 and 3 from row group 0:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::row_groups_and_columns([0], [1, 3]);
+/// ```
+///
+/// [Page Index]: https://parquet.apache.org/docs/file-format/pageindex/
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct ColumnChunkMask {
+    // using i32 because that's how thrift vectors are sized
+    row_groups: Option<Arc<BTreeSet<i32>>>,
+    columns: Option<Arc<BTreeSet<i32>>>,
+}
+
+impl ColumnChunkMask {
+    /// Select all row groups and columns.
+    pub fn all() -> Self {
+        Self::default()
+    }
+
+    /// Select only the listed columns.
+    ///
+    /// Any indices in `columns` that are less than zero will be ignored. 
Passing an empty
+    /// set is treated the same as selecting all columns.
+    pub fn columns(columns: impl IntoIterator<Item = i32>) -> Self {
+        Self {
+            row_groups: None,
+            columns: Self::iter_to_set(columns),
+        }
+    }
+
+    /// Select only the listed row groups.
+    ///
+    /// Any indices in `row_groups` that are less than zero will be ignored. 
Passing an empty
+    /// set is treated the same as selecting all row groups.
+    pub fn row_groups(row_groups: impl IntoIterator<Item = i32>) -> Self {
+        Self {
+            row_groups: Self::iter_to_set(row_groups),
+            columns: None,
+        }
+    }
+
+    /// Select only the listed row groups and columns.
+    ///
+    /// Any indices in `row_groups` or `columns` that are less than zero will 
be ignored.
+    /// Passing an empty set for `row_groups` is treated as selecting all row 
groups, and
+    /// an empty set for `columns` as selectiong all columns.

Review Comment:
   **Nit (C16).** Typo.
   
   ```suggestion
       /// an empty set for `columns` as selecting all columns.
   ```



##########
parquet/src/file/metadata/reader.rs:
##########
@@ -104,6 +107,114 @@ impl From<bool> for PageIndexPolicy {
     }
 }
 
+/// Struct to specify column chunks for which metadata is required.
+///
+/// Column chunks are identified by row group index and column index. This 
struct
+/// allows for specifying vertical slices of column chunk data (via 
[`Self::columns`]),
+/// horizontal slices (via [`Self::row_groups`]), or the intersection of the 
two
+/// (via [`Self::row_groups_and_columns`]).
+///
+/// At present this is only used to select elements of the [Page Index] for 
decoding.
+///
+/// # Examples
+///
+/// To select columns 0 and 1 from all row groups:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::columns([0, 1]);
+/// ```
+///
+/// To select all columns from row group 2:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::row_groups([2]);
+/// ```
+///
+/// To select columns 1 and 3 from row group 0:
+/// ```rust
+/// # use parquet::file::metadata::ColumnChunkMask;
+/// let mask = ColumnChunkMask::row_groups_and_columns([0], [1, 3]);
+/// ```
+///
+/// [Page Index]: https://parquet.apache.org/docs/file-format/pageindex/
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct ColumnChunkMask {
+    // using i32 because that's how thrift vectors are sized
+    row_groups: Option<Arc<BTreeSet<i32>>>,
+    columns: Option<Arc<BTreeSet<i32>>>,
+}
+
+impl ColumnChunkMask {
+    /// Select all row groups and columns.
+    pub fn all() -> Self {
+        Self::default()
+    }
+
+    /// Select only the listed columns.
+    ///
+    /// Any indices in `columns` that are less than zero will be ignored. 
Passing an empty
+    /// set is treated the same as selecting all columns.
+    pub fn columns(columns: impl IntoIterator<Item = i32>) -> Self {
+        Self {
+            row_groups: None,
+            columns: Self::iter_to_set(columns),
+        }
+    }
+
+    /// Select only the listed row groups.
+    ///
+    /// Any indices in `row_groups` that are less than zero will be ignored. 
Passing an empty
+    /// set is treated the same as selecting all row groups.
+    pub fn row_groups(row_groups: impl IntoIterator<Item = i32>) -> Self {
+        Self {
+            row_groups: Self::iter_to_set(row_groups),
+            columns: None,
+        }
+    }
+
+    /// Select only the listed row groups and columns.
+    ///
+    /// Any indices in `row_groups` or `columns` that are less than zero will 
be ignored.
+    /// Passing an empty set for `row_groups` is treated as selecting all row 
groups, and
+    /// an empty set for `columns` as selectiong all columns.
+    pub fn row_groups_and_columns(
+        row_groups: impl IntoIterator<Item = i32>,
+        columns: impl IntoIterator<Item = i32>,
+    ) -> Self {
+        Self {
+            row_groups: Self::iter_to_set(row_groups),
+            columns: Self::iter_to_set(columns),
+        }
+    }
+
+    /// Test if `idx` is in the row group set.
+    ///
+    /// Returns `false` if `idx > `[`i32::MAX`].
+    pub fn includes_row_group(&self, idx: usize) -> bool {
+        let Ok(idx) = i32::try_from(idx) else {
+            return false;
+        };
+        self.row_groups
+            .as_ref()
+            .is_none_or(|keep| keep.contains(&idx))
+    }
+
+    /// Test if `idx` is in the column set.
+    ///
+    /// Returns `false` if `idx > `[`i32::MAX`].
+    pub fn includes_column(&self, idx: usize) -> bool {
+        let Ok(idx) = i32::try_from(idx) else {
+            return false;
+        };
+        self.columns.as_ref().is_none_or(|keep| keep.contains(&idx))
+    }
+
+    fn iter_to_set(indices: impl IntoIterator<Item = i32>) -> 
Option<Arc<BTreeSet<i32>>> {
+        let set: BTreeSet<i32> = indices.into_iter().filter(|&i| i >= 
0).collect();
+        (!set.is_empty()).then_some(Arc::new(set))
+    }

Review Comment:
   **API, one-way door (C4).** An empty set selects everything. The common 
callers build the set from a list that can be empty:
   
   ```text
   columns([])                     -> loads 12 of 12 offset indexes   (SELECT 
count(*): no projected leaves)
   row_groups(<no surviving rgs>)  -> loads 12 of 12                  (stats 
pruned every row group)
   columns([-1])                   -> loads 12 of 12
   columns([100])                  -> loads 0 of 12, page_index() = None
   columns([-1, 100])              -> loads 0 of 12
   ```
   
   The neighbour APIs do the opposite: `ProjectionMask::leaves(schema, [])` 
selects no leaves, `with_row_groups(vec![])` reads nothing, and 
`ParquetStatisticsPolicy::skip_except(&[])` is `SkipAll`. After a release, a 
change here silently changes what callers load. Suggestion: an empty set 
selects nothing, and `all()` stays the only way to select everything. 
`test_chunk_mask` (L1545-1549) and the constructor docs (L155-156, L166-167, 
L177-179) need the same change.
   
   ```suggestion
       fn iter_to_set(indices: impl IntoIterator<Item = i32>) -> 
Option<Arc<BTreeSet<i32>>> {
           // An empty set selects nothing. `Self::all()` selects everything.
           Some(Arc::new(indices.into_iter().filter(|&i| i >= 0).collect()))
       }
   ```



##########
parquet/src/arrow/arrow_reader/mod.rs:
##########
@@ -970,7 +1012,9 @@ impl ParquetMetaDataReader {
         {
             self = self
                 .with_column_index_policy(options.column_index_policy())
-                .with_offset_index_policy(options.offset_index_policy());
+                .with_offset_index_policy(options.offset_index_policy())
+                .with_column_index_mask(options.column_index_mask().clone())
+                .with_offset_index_mask(options.offset_index_mask().clone());
         }

Review Comment:
   **Footgun (C7).** Masks are silently ignored in these paths (outputs from 
this head, 4 columns x 3 row groups):
   
   ```text
   options.with_offset_index_mask([0]) only (policy default Skip)       -> 
page_index() = None
   preloading reader + with_arrow_reader_options(mask [0], policy Skip)  -> 
loads 12 of 12 offset indexes
   ParquetObjectReader::with_preload_*(true) + options masks [0]         -> 
loads all columns (store.rs L239-248 has its own copy of this code)
   ArrowReaderMetadata::try_new(md with mask [0], Required + mask [3])   -> Ok, 
keeps mask [0], no error
   ```
   
   This suggestion fixes the second line (then: 3 of 12, only column 0). The 
same change is needed in `store.rs`. The docs change on 
`ArrowReaderOptions::with_*_mask` covers the other lines.
   
   ```suggestion
           {
               self = self
                   .with_column_index_policy(options.column_index_policy())
                   .with_offset_index_policy(options.offset_index_policy());
           }
   
           // Apply the masks also when a preload setting on this reader 
enables the page index
           if options.column_index_mask() != &ColumnChunkMask::all() {
               self = 
self.with_column_index_mask(options.column_index_mask().clone());
           }
           if options.offset_index_mask() != &ColumnChunkMask::all() {
               self = 
self.with_offset_index_mask(options.offset_index_mask().clone());
           }
   ```



##########
parquet/src/file/metadata/reader.rs:
##########
@@ -137,6 +248,24 @@ impl ParquetMetaDataReader {
         self
     }
 
+    /// Selects the row groups and columns for which both page index 
structures are read.
+    pub fn with_page_index_mask(self, mask: ColumnChunkMask) -> Self {
+        self.with_column_index_mask(mask.clone())
+            .with_offset_index_mask(mask)
+    }
+
+    /// Selects the row groups and columns for which column indexes are read.
+    pub fn with_column_index_mask(mut self, mask: ColumnChunkMask) -> Self {
+        self.column_index_mask = mask;
+        self
+    }
+
+    /// Selects the row groups and columns for which offset indexes are read.
+    pub fn with_offset_index_mask(mut self, mask: ColumnChunkMask) -> Self {
+        self.offset_index_mask = mask;
+        self
+    }

Review Comment:
   **Perf / Docs (C8, C12).** This reader merges all requested ranges into one 
covering range (`needs_index_data`, L959-966), and the async path does the 
same. So a column mask cuts decode work, but almost no I/O:
   
   ```text
   4 columns x 10 row groups      ParquetMetaDataReader             push decoder
   all()                          1625 B in 1 request               1 range,   
1625 B
   columns([0])                   1586 B in 1 request               20 ranges,  
406 B
   row_groups_and_columns([9],[3])  533 B in 1 request               2 ranges,  
  41 B
   ```
   
   Options: fetch the ranges with a gap threshold (like 
`object_store::coalesce_ranges`), or document it. This suggestion documents it, 
and also the `Skip` and `Required` behavior (C7, C12).
   
   ```suggestion
       /// Selects the row groups and columns for which both page index 
structures are read.
       ///
       /// See [`Self::with_column_index_mask`] and 
[`Self::with_offset_index_mask`].
       pub fn with_page_index_mask(self, mask: ColumnChunkMask) -> Self {
           self.with_column_index_mask(mask.clone())
               .with_offset_index_mask(mask)
       }
   
       /// Selects the row groups and columns for which column indexes are read.
       ///
       /// The mask applies only if the column index policy is not 
[`PageIndexPolicy::Skip`]
       /// (the default). The mask reduces decode time, but not I/O: this 
reader fetches one
       /// byte range that covers all selected column indexes.
       pub fn with_column_index_mask(mut self, mask: ColumnChunkMask) -> Self {
           self.column_index_mask = mask;
           self
       }
   
       /// Selects the row groups and columns for which offset indexes are read.
       ///
       /// The mask applies only if the offset index policy is not 
[`PageIndexPolicy::Skip`]
       /// (the default). [`PageIndexPolicy::Required`] checks only the 
selected column chunks.
       /// The mask reduces decode time, but not I/O: this reader fetches one 
byte range that
       /// covers all selected offset indexes.
       pub fn with_offset_index_mask(mut self, mask: ColumnChunkMask) -> Self {
           self.offset_index_mask = mask;
           self
       }
   ```



##########
parquet/src/file/metadata/push_decoder.rs:
##########
@@ -408,30 +435,36 @@ impl ParquetMetaDataPushDecoder {
                 DecodeState::ReadingPageIndex(mut metadata) => {
                     // First determine if any page indexes are needed based on
                     // the specified policies
-                    let range = range_for_page_index(
+                    let ranges = ranges_for_page_index(
                         &metadata,
                         self.column_index_policy,
                         self.offset_index_policy,
+                        &self.column_index_mask,
+                        &self.offset_index_mask,
                     );
 
-                    let Some(page_index_range) = range else {
+                    if ranges.is_empty() {
                         self.state = DecodeState::Finished;
                         return Ok(DecodeResult::Data(*metadata));
-                    };
+                    }
 
-                    if !self.buffers.has_range(&page_index_range) {
+                    let needed_ranges = ranges
+                        .into_iter()
+                        .filter(|r| !self.buffers.has_range(r))
+                        .collect::<Vec<_>>();

Review Comment:
   **Perf (C9).** `PushBuffers::has_range` (here) and `PushBuffers::get_bytes` 
(once per chunk in `parse_*_index`) are linear scans over all pushed buffers. 
With a sparse mask, a caller that pushes the requested ranges as they are (as 
the docs show) gets O(ranges x buffers). Release build, Int32 columns, mask = 
every other column, 3 runs on a shared machine:
   
   | row groups x columns | ranges | one buffer per range | one covering buffer 
|
   |---|---|---|---|
   | 100 x 100 | 10,000 | 49-203 ms | 3-16 ms |
   | 300 x 100 | 30,000 | 0.97-1.6 s | 24-40 ms |
   
   Fix: keep the buffers sorted by start and use a binary search in `has_range` 
/ `get_bytes`, and cache the computed ranges in 
`DecodeState::ReadingPageIndex`. Smaller change: reuse the `Vec` here (no new 
allocation).
   
   ```suggestion
                       let mut needed_ranges = ranges;
                       needed_ranges.retain(|r| !self.buffers.has_range(r));
   ```



##########
parquet/src/file/metadata/parser.rs:
##########
@@ -293,27 +296,27 @@ pub(crate) fn parse_page_index(
 fn parse_column_index(
     metadata: &ParquetMetaData,
     column_index_policy: PageIndexPolicy,
+    mask: &ColumnChunkMask,
     page_index_builder: &mut PageIndexBuilder,
-    bytes: &Bytes,
-    start_offset: u64,
+    bytes: &PushBuffers,
 ) -> crate::errors::Result<()> {
     if column_index_policy == PageIndexPolicy::Skip {
         return Ok(());
     }
     for rg_idx in 0..metadata.num_row_groups() {
+        if !mask.includes_row_group(rg_idx) {
+            continue;
+        }
         let rg = metadata.row_group(rg_idx);
         for col_idx in 0..rg.num_columns() {
+            if !mask.includes_column(col_idx) {
+                continue;
+            }

Review Comment:
   **Perf nit (C15).** For each selected row group, this loop visits every 
column and does a `BTreeSet` lookup, also when the mask selects 1 of 10,000 
columns. `add_ranges` in `push_decoder.rs` does the same, on every 
`try_decode`. A `pub(crate)` helper can iterate the selected set directly 
(compiled and tested on this head):
   
   ```rust
   // reader.rs
   impl ColumnChunkMask {
       /// Selected column indexes in `0..num_columns`, in order
       pub(crate) fn column_indices(
           &self,
           num_columns: usize,
       ) -> Box<dyn Iterator<Item = usize> + '_> {
           match &self.columns {
               None => Box::new(0..num_columns),
               Some(set) => {
                   let end = i32::try_from(num_columns).unwrap_or(i32::MAX);
                   Box::new(set.range(0..end).map(|&i| i as usize))
               }
           }
       }
   }
   
   // here, and in parse_offset_index / add_ranges
   for col_idx in mask.column_indices(rg.num_columns()) {
   ```



##########
parquet/src/file/metadata/parser.rs:
##########
@@ -244,14 +245,16 @@ pub(crate) fn decode_metadata(
 ///   Required, Optional, Skip).
 /// * `offset_index_policy` - The policy for handling offset index parsing 
(e.g.,
 ///   Required, Optional, Skip).
-/// * `bytes` - The byte slice containing the page index data.
+/// * `bytes` - [`PushBuffers`] that should have already been populated with 
the bytes containing
+///   the page indexes.
 /// * `start_offset` - The offset where `bytes` begin in the file.

Review Comment:
   **Nit (C16).** `start_offset` is no longer a parameter.
   
   ```suggestion
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to