This is an automated email from the ASF dual-hosted git repository.
etseidl 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 421b0c696b parquet: Add end-to-end test of custom `PageIndexProvider`
(#11132)
421b0c696b is described below
commit 421b0c696b636ccc583da35688655d5a79fa1bfb
Author: Ed Seidl <[email protected]>
AuthorDate: Thu Sep 24 13:27:31 2026 -0700
parquet: Add end-to-end test of custom `PageIndexProvider` (#11132)
# Which issue does this PR close?
- Closes #11029.
# Rationale for this change
Add test to demonstrate that read functionality is preserved when using
a custom `PageIndexProvider`.
# What changes are included in this PR?
Adds test
# Are these changes tested?
N/A
# Are there any user-facing changes?
No
# AI Usage
The test was written by Claude Code and lightly edited by me
---------
Co-authored-by: Adrian Garcia Badaracco
<[email protected]>
---
.../arrow_reader/custom_page_index_provider.rs | 468 +++++++++++++++++++++
parquet/tests/arrow_reader/mod.rs | 1 +
2 files changed, 469 insertions(+)
diff --git a/parquet/tests/arrow_reader/custom_page_index_provider.rs
b/parquet/tests/arrow_reader/custom_page_index_provider.rs
new file mode 100644
index 0000000000..147c9a7336
--- /dev/null
+++ b/parquet/tests/arrow_reader/custom_page_index_provider.rs
@@ -0,0 +1,468 @@
+// 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.
+
+//! End-to-end test for custom PageIndexProvider implementation.
+//!
+//! This test validates reading with a custom PageIndexProvider through the
+//! actual read path, ensuring that:
+//! - Columns with page indexes use offset-index-driven fetch
+//! - Columns without page indexes fall back to whole-column-chunk fetching
+//! - Correct results are returned in both cases
+
+use arrow::compute::concat_batches;
+use arrow_array::{Int32Array, RecordBatch, StringArray};
+use arrow_schema::{DataType, Field, Schema};
+use bytes::Bytes;
+use parquet::DecodeResult;
+use parquet::arrow::ArrowWriter;
+use parquet::arrow::arrow_reader::{
+ ArrowReaderMetadata, ArrowReaderOptions, ParquetRecordBatchReaderBuilder,
RowSelection,
+ RowSelectionPolicy, RowSelector,
+};
+use parquet::arrow::push_decoder::ParquetPushDecoderBuilder;
+use parquet::file::metadata::page_index::PageIndexProvider;
+use parquet::file::metadata::{PageIndexPolicy, ParquetMetaData};
+use parquet::file::page_index::column_index::ColumnIndexMetaData;
+use parquet::file::page_index::index_reader::{decode_column_index,
decode_offset_index};
+use parquet::file::page_index::offset_index::OffsetIndexMetaData;
+use parquet::file::properties::{EnabledStatistics, WriterProperties};
+use std::collections::HashMap;
+use std::ops::Range;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+#[test]
+fn test_read_with_custom_page_index_provider_sync() {
+ run_test(Reader::Sync);
+}
+
+#[test]
+fn test_read_with_custom_page_index_provider_push() {
+ run_test(Reader::Push);
+}
+
+#[cfg(feature = "async")]
+#[test]
+fn test_read_with_custom_page_index_provider_async() {
+ run_test(Reader::Async);
+}
+
+#[derive(Debug, Clone, Copy)]
+enum Reader {
+ Sync,
+ Push,
+ #[cfg(feature = "async")]
+ Async,
+}
+
+/// Read `file` with `reader`, using `metadata` and `selection`
+fn read(
+ reader: Reader,
+ file: Bytes,
+ metadata: ArrowReaderMetadata,
+ selection: RowSelection,
+ requested: &mut Vec<Range<u64>>,
+) -> Vec<RecordBatch> {
+ match reader {
+ Reader::Sync =>
ParquetRecordBatchReaderBuilder::new_with_metadata(file, metadata)
+ .with_row_selection(selection)
+ .with_row_selection_policy(RowSelectionPolicy::Selectors)
+ .build()
+ .unwrap()
+ .collect::<Result<_, _>>()
+ .unwrap(),
+ Reader::Push => {
+ let mut decoder =
ParquetPushDecoderBuilder::new_with_metadata(metadata)
+ .with_row_selection(selection)
+ .with_row_selection_policy(RowSelectionPolicy::Selectors)
+ .build()
+ .unwrap();
+ let mut batches = vec![];
+ loop {
+ match decoder.try_decode().unwrap() {
+ DecodeResult::NeedsData(ranges) => {
+ requested.extend(ranges.iter().cloned());
+ let data = ranges
+ .iter()
+ .map(|r| file.slice(r.start as usize..r.end as
usize))
+ .collect();
+ decoder.push_ranges(ranges, data).unwrap();
+ }
+ DecodeResult::Data(batch) => batches.push(batch),
+ DecodeResult::Finished => return batches,
+ }
+ }
+ }
+ #[cfg(feature = "async")]
+ Reader::Async => {
+ use futures::TryStreamExt;
+ let input = std::io::Cursor::new(file.to_vec());
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .build()
+ .unwrap();
+ rt.block_on(async {
+
parquet::arrow::ParquetRecordBatchStreamBuilder::new_with_metadata(input,
metadata)
+ .with_row_selection(selection)
+ .with_row_selection_policy(RowSelectionPolicy::Selectors)
+ .build()
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap()
+ })
+ }
+ }
+}
+
+fn run_test(reader: Reader) {
+ // Step 1: Write a parquet file with page indexes
+ let file_bytes = create_test_file();
+
+ // Step 2: Load metadata WITHOUT page indexes initially
+ let builder = ParquetRecordBatchReaderBuilder::try_new_with_options(
+ file_bytes.clone(),
+
ArrowReaderOptions::default().with_page_index_policy(PageIndexPolicy::Skip),
+ )
+ .unwrap();
+
+ let metadata = builder.metadata().as_ref();
+
+ // Verify we have multiple row groups
+ assert_eq!(metadata.num_row_groups(), 3);
+
+ // Step 3: Create a custom provider and populate it selectively
+ // Simulate a scenario where:
+ // - We only populate indexes for row groups 0 and 2 (skipping row group 1)
+ // - For row group 0: populate column 0 (id) and column 1 (value)
+ // - For row group 2: populate columns 0 (id) and 2 (name), so column 1 is
a gap
+ let provider = Arc::new(SelectivePageIndexProvider::new(
+ file_bytes.clone(),
+ metadata,
+ // values are (row_group index, column index)
+ &[(0, 0), (0, 1), (2, 0), (2, 2)],
+ ));
+
+ // Step 4: Install the custom provider into metadata
+ let metadata_builder = metadata
+ .clone()
+ .into_builder()
+ .set_page_index(Some(provider.clone()));
+ let metadata_with_custom_index = Arc::new(metadata_builder.build());
+
+ // Step 5: Create ArrowReaderMetadata with the custom page index
+ let arrow_metadata = ArrowReaderMetadata::try_new(
+ metadata_with_custom_index.clone(),
+ ArrowReaderOptions::default(),
+ )
+ .unwrap();
+
+ // Step 6: Read data with RowSelection that triggers page skipping
+
+ // Create a RowSelection that:
+ // - Selects rows 20-30 (in row group 0)
+ // - Selects rows 60-70 (in row group 1)
+ // - Selects rows 120-130 (in row group 2)
+ let selection = RowSelection::from(vec![
+ // Skip first 20 rows
+ RowSelector::skip(20),
+ // Select rows 20-30
+ RowSelector::select(10),
+ // Skip rows 30-60
+ RowSelector::skip(30),
+ // Select rows 60-70
+ RowSelector::select(10),
+ // Skip rows 70-120
+ RowSelector::skip(50),
+ // Select rows 120-130
+ RowSelector::select(10),
+ ]);
+
+ // Collect all batches
+ let mut requested = Vec::<Range<u64>>::new();
+ let batches = read(
+ reader,
+ file_bytes.clone(),
+ arrow_metadata,
+ selection,
+ &mut requested,
+ );
+
+ // Step 7: Check statistics
+
+ // only the push test will populate requested
+ if matches!(reader, Reader::Push) {
+ let fetched = |rg: usize, col: usize| -> u64 {
+ let (start, len) = metadata.row_group(rg).column(col).byte_range();
+ requested
+ .iter()
+ .filter(|r| r.start >= start && r.end <= start + len)
+ .map(|r| r.end - r.start)
+ .sum()
+ };
+ let chunk_len = |rg: usize, col: usize|
metadata.row_group(rg).column(col).byte_range().1;
+ assert!(fetched(0, 0) < chunk_len(0, 0)); // offset index: selected
pages only
+ assert_eq!(fetched(0, 2), chunk_len(0, 2)); // no offset index: whole
chunk
+ }
+
+ // Check that the custom provider was used
+ let (hits, misses) = provider.stats();
+ assert!(hits > 0, "provider should have hits");
+ assert!(misses > 0, "provider should have misses");
+
+ // Since we didn't use predicates, the column index should be untouched
+ let (col_hits, col_misses) = provider.column_index_stats();
+ assert_eq!(col_hits, 0, "column index should not be used");
+ assert_eq!(col_misses, 0, "column index should not be used");
+
+ let (off_hits, off_misses) = provider.offset_index_stats();
+ assert!(off_hits > 0, "offset index should have hits");
+ assert!(off_misses > 0, "offset index should have misses");
+
+ // Step 8: Verify correct results
+ // We expect 30 rows total: 10 from each selected range
+ let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+ assert_eq!(total_rows, 30);
+
+ // Expected values for the selected rows:
+ // rows 20-30: id=20..30, value=40..60, score=60..90
+ // rows 60-70: id=60..70, value=120..140, score=180..210
+ // rows 120-130: id=120..130, value=240..260, score=360..390
+ let ids: Vec<i32> = (20..30).chain(60..70).chain(120..130).collect();
+ let expected = make_batch(&ids);
+ assert_eq!(
+ concat_batches(&expected.schema(), &batches).unwrap(),
+ expected
+ );
+}
+
+/// A custom PageIndexProvider that only stores indexes for a subset of columns
+///
+/// This provider mimics a real-world scenario where an application has loaded
+/// page indexes selectively based on query predicates and projections.
+#[derive(Debug)]
+struct SelectivePageIndexProvider {
+ // indexes are accessed first by row_group index and then by column index
+ column_indexes: Option<HashMap<(usize, usize), ColumnIndexMetaData>>,
+ offset_indexes: Option<HashMap<(usize, usize), OffsetIndexMetaData>>,
+ // Usage statistics
+ column_index_hits: AtomicUsize,
+ column_index_misses: AtomicUsize,
+ offset_index_hits: AtomicUsize,
+ offset_index_misses: AtomicUsize,
+}
+
+impl SelectivePageIndexProvider {
+ fn new(file_bytes: Bytes, metadata: &ParquetMetaData, chunks: &[(usize,
usize)]) -> Self {
+ let mut column_indexes = HashMap::new();
+ let mut offset_indexes = HashMap::new();
+ for &(rg, col) in chunks {
+ column_indexes.insert(
+ (rg, col),
+ Self::fetch_column_index(rg, col, metadata, &file_bytes)
+ .unwrap()
+ .unwrap(),
+ );
+ offset_indexes.insert(
+ (rg, col),
+ Self::fetch_offset_index(rg, col, metadata, &file_bytes)
+ .unwrap()
+ .unwrap(),
+ );
+ }
+ Self {
+ column_indexes: Some(column_indexes),
+ offset_indexes: Some(offset_indexes),
+ column_index_hits: AtomicUsize::new(0),
+ column_index_misses: AtomicUsize::new(0),
+ offset_index_hits: AtomicUsize::new(0),
+ offset_index_misses: AtomicUsize::new(0),
+ }
+ }
+
+ /// Get statistics about column index usage
+ fn column_index_stats(&self) -> (usize, usize) {
+ (
+ self.column_index_hits.load(Ordering::Relaxed),
+ self.column_index_misses.load(Ordering::Relaxed),
+ )
+ }
+
+ /// Get statistics about offset index usage
+ fn offset_index_stats(&self) -> (usize, usize) {
+ (
+ self.offset_index_hits.load(Ordering::Relaxed),
+ self.offset_index_misses.load(Ordering::Relaxed),
+ )
+ }
+
+ /// Get combined statistics as (hits, misses) for both index types
+ fn stats(&self) -> (usize, usize) {
+ let (col_hits, col_misses) = self.column_index_stats();
+ let (off_hits, off_misses) = self.offset_index_stats();
+ (col_hits + off_hits, col_misses + off_misses)
+ }
+
+ /// Fetch and parse the column index for the given row group and column
+ fn fetch_column_index(
+ row_group_idx: usize,
+ column_idx: usize,
+ metadata: &ParquetMetaData,
+ file_bytes: &Bytes,
+ ) -> parquet::errors::Result<Option<ColumnIndexMetaData>> {
+ let column = metadata.row_group(row_group_idx).column(column_idx);
+ let range = column.column_index_range();
+ if let Some(range) = range {
+ let idx_bytes = file_bytes.slice(range.start as usize..range.end
as usize);
+ Ok(Some(decode_column_index(&idx_bytes, column.column_type())?))
+ } else {
+ Ok(None)
+ }
+ }
+
+ /// Fetch and parse the offset index for the given row group and column
+ fn fetch_offset_index(
+ row_group_idx: usize,
+ column_idx: usize,
+ metadata: &ParquetMetaData,
+ file_bytes: &Bytes,
+ ) -> parquet::errors::Result<Option<OffsetIndexMetaData>> {
+ let column = metadata.row_group(row_group_idx).column(column_idx);
+ let range = column.offset_index_range();
+ if let Some(range) = range {
+ let idx_bytes = file_bytes.slice(range.start as usize..range.end
as usize);
+ Ok(Some(decode_offset_index(&idx_bytes)?))
+ } else {
+ Ok(None)
+ }
+ }
+}
+
+impl PageIndexProvider for SelectivePageIndexProvider {
+ fn has_offset_indexes(&self) -> bool {
+ self.offset_indexes.is_some()
+ }
+
+ fn has_column_indexes(&self) -> bool {
+ self.column_indexes.is_some()
+ }
+
+ fn column_index(
+ &self,
+ row_group_idx: usize,
+ column_idx: usize,
+ ) -> Option<&ColumnIndexMetaData> {
+ let result = self
+ .column_indexes
+ .as_ref()?
+ .get(&(row_group_idx, column_idx));
+
+ if result.is_some() {
+ self.column_index_hits.fetch_add(1, Ordering::Relaxed);
+ } else {
+ self.column_index_misses.fetch_add(1, Ordering::Relaxed);
+ }
+
+ result
+ }
+
+ fn offset_index(
+ &self,
+ row_group_idx: usize,
+ column_idx: usize,
+ ) -> Option<&OffsetIndexMetaData> {
+ let result = self
+ .offset_indexes
+ .as_ref()?
+ .get(&(row_group_idx, column_idx));
+
+ if result.is_some() {
+ self.offset_index_hits.fetch_add(1, Ordering::Relaxed);
+ } else {
+ self.offset_index_misses.fetch_add(1, Ordering::Relaxed);
+ }
+
+ result
+ }
+
+ fn as_any(&self) -> &dyn std::any::Any {
+ self
+ }
+}
+
+/// Create a test parquet file with multiple row groups and multiple pages per
column
+fn create_test_file() -> Bytes {
+ // Create small pages and small row groups for testing
+ let props = WriterProperties::builder()
+ .set_statistics_enabled(EnabledStatistics::Page)
+ .set_data_page_row_count_limit(10) // Small pages for multiple pages
per column
+ .set_write_batch_size(10)
+ .set_max_row_group_row_count(Some(50)) // Small row groups
+ .build();
+
+ // Write 3 row groups with 50 rows each (150 rows total)
+ let batch = make_batch((0..150).collect::<Vec<i32>>().as_slice());
+ let mut buffer = Vec::with_capacity(1024);
+ let mut writer =
+ ArrowWriter::try_new(&mut buffer, batch.schema().clone(),
Some(props)).unwrap();
+ writer.write(&batch).unwrap();
+
+ // check that file has properties we wanted (3 row groups, 5 pages per
chunk)
+ let metadata = writer.close().unwrap();
+ assert_eq!(metadata.num_row_groups(), 3);
+ assert!(metadata.page_index().is_some());
+ let page_index = metadata.page_index().unwrap();
+ for row_group_idx in 0..3 {
+ for column_idx in 0..4 {
+ assert!(page_index.column_index(row_group_idx,
column_idx).is_some());
+ assert!(page_index.offset_index(row_group_idx,
column_idx).is_some());
+ assert!(
+ page_index
+ .num_data_pages(row_group_idx, column_idx)
+ .is_some_and(|n| n == 5)
+ );
+ }
+ }
+ Bytes::from(buffer)
+}
+
+fn make_batch(ids: &[i32]) -> RecordBatch {
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int32, false),
+ Field::new("value", DataType::Int32, false),
+ Field::new("name", DataType::Utf8, false),
+ Field::new("score", DataType::Int32, false),
+ ]));
+ // Write 3 row groups with 50 rows each (150 rows total)
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids.to_vec())),
+ Arc::new(Int32Array::from(
+ ids.iter().map(|x| x * 2).collect::<Vec<i32>>(),
+ )),
+ Arc::new(StringArray::from(
+ ids.iter()
+ .map(|x| format!("name{x}"))
+ .collect::<Vec<String>>(),
+ )),
+ Arc::new(Int32Array::from(
+ ids.iter().map(|x| x * 3).collect::<Vec<i32>>(),
+ )),
+ ],
+ )
+ .unwrap()
+}
diff --git a/parquet/tests/arrow_reader/mod.rs
b/parquet/tests/arrow_reader/mod.rs
index f16cdf2b30..1dfcfb6901 100644
--- a/parquet/tests/arrow_reader/mod.rs
+++ b/parquet/tests/arrow_reader/mod.rs
@@ -42,6 +42,7 @@ mod bad_data;
mod bloom_filter;
#[cfg(feature = "crc")]
mod checksum;
+mod custom_page_index_provider;
mod int96_stats_roundtrip;
mod invalid_utf8;
mod io;