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 75406dc4f3 fix(parquet): prevent cached Mask reads from crossing 
unloaded sparse pages (#10735)
75406dc4f3 is described below

commit 75406dc4f3e75be2eb6d66a7733a549229414d90
Author: Huang Qiwei <[email protected]>
AuthorDate: Thu Aug 20 05:26:50 2026 +0800

    fix(parquet): prevent cached Mask reads from crossing unloaded sparse pages 
(#10735)
    
    # Proposed title
    
    fix(parquet): prevent cached Mask reads from crossing unloaded sparse
    pages
    
    # Which issue does this PR close?
    
    - Closes #10733.
    
    # Rationale for this change
    
    The async Parquet reader may combine page pruning, predicate caching,
    and a Mask-backed row selection. `MaskCursor::next_chunk` bounds each
    chunk by the current loaded row range, but previously retained all
    trailing skipped rows up to that boundary.
    
    For example, if a loaded range starts with one selected row and the rest
    of the range is skipped, the cursor returned the whole loaded range as
    `chunk_rows`. A fixed-size cached reader can then extend that read
    beyond the cached segment and into a sparse page that was never loaded,
    producing:
    
    ```text
    Invalid offset in sparse column chunk data: ..., no matching page found
    ```
    
    DataFusion predicate pushdown exposed this in TPC-DS queries 66, 75, and
    81. Disabling the predicate cache avoided the failure, but the cache is
    enabled by default and should be safe with both Auto and explicit Mask
    selection.
    
    # What changes are included in this PR?
    
    - Track the position immediately after the last selected row in the
    current loaded range.
    - Use that position for both `MaskCursor::position` and
    `MaskChunk::chunk_rows`, instead of the scan position at the end of the
    loaded range.
    - Leave trailing skipped rows to the next cursor step. They become part
    of `initial_skip`, allowing `ArrayReader::skip_records` to cross
    unloaded pages.
    - Update the read-plan unit test to lock down the chunk boundaries.
    - Add an async regression test with three pages, a cached predicate
    column, a sparse initial selection, and both Auto and explicit Mask
    policies.
    
    The selected rows and output batch semantics are unchanged. The fix only
    avoids decoding trailing rows that are not selected.
    
    # Are these changes tested?
    
    Yes.
    
    Focused regression test:
    
    ```shell
    cargo test -p parquet --features async --test arrow_reader 
'row_filter::r#async::test_cached_mask_reads_sparse_pages_without_error' -- 
--exact --nocapture
    ```
    
    - Test-only commit on top of `bb1e6cd070`: failed with the sparse-column
    offset error.
    - This fix: 1 passed, 0 failed.
    - Mask-focused `arrow_reader` tests: 7 passed, 0 failed.
    
    I also ran one-iteration end-to-end correctness smoke tests with
    DataFusion `52964c2966e855f47b96a15d1aace01baee1f2c1`. For validation
    only, the Arrow default policy was changed to Mask on top of this PR so
    the workloads could not fall back to Selectors:
    
    - TPC-DS SF10: 99/99 queries succeeded. The 96 queries that had
    successful pre-fix results had identical row counts; the previously
    failing Q66, Q75, and Q81 also succeeded.
    - TPC-H SF10: 22/22 queries succeeded, with identical row counts to the
    pre-fix baseline.
    - ClickBench: 43/43 queries succeeded with predicate pushdown explicitly
    enabled, with identical row counts to the pre-fix baseline.
    - No `invalid offset`, `no matching page`, panic, or query failure was
    found in the candidate logs.
    
    These workload runs are correctness smoke tests, not performance claims.
    
    ## Suggested CI follow-up
    
    cc @alamb
    This bug requires an interaction between predicate pushdown, page
    pruning, selection representation, and predicate caching, which is
    difficult to cover with isolated reader tests alone. I suggest adding a
    CI or scheduled benchmark correctness check that runs representative
    TPC-DS, TPC-H, and ClickBench queries with **Parquet predicate pushdown
    enabled**;
    
    # Are there any user-facing changes?
    
    No API changes. Async Parquet scans using predicate caching and sparse
    page reads no longer fail when Auto or explicit Mask selection is used.
---
 .../src/arrow/array_reader/cached_array_reader.rs  |  3 +
 parquet/src/arrow/arrow_reader/mod.rs              | 10 ++--
 parquet/src/arrow/arrow_reader/read_plan.rs        | 10 ++--
 parquet/src/arrow/arrow_reader/selection/cursor.rs | 29 +++++----
 .../src/arrow/push_decoder/reader_builder/mod.rs   |  4 +-
 parquet/tests/arrow_reader/row_filter/async.rs     | 70 +++++++++++++++++++++-
 6 files changed, 104 insertions(+), 22 deletions(-)

diff --git a/parquet/src/arrow/array_reader/cached_array_reader.rs 
b/parquet/src/arrow/array_reader/cached_array_reader.rs
index 5e4d91ce30..012fa25481 100644
--- a/parquet/src/arrow/array_reader/cached_array_reader.rs
+++ b/parquet/src/arrow/array_reader/cached_array_reader.rs
@@ -135,6 +135,9 @@ impl CachedArrayReader {
             self.inner_position += skipped;
         }
 
+        // For sparse mask reads, this full-batch fallback relies on 
`MaskCursor`
+        // ending every chunk at a selected row. Predicate fetch expands cached
+        // columns to batch boundaries, so the batch containing that row is 
loaded.
         let read = self.inner.read_records(self.batch_size)?;
 
         // If there are no remaining records (EOF), return immediately without
diff --git a/parquet/src/arrow/arrow_reader/mod.rs 
b/parquet/src/arrow/arrow_reader/mod.rs
index aafd60880e..52e7461835 100644
--- a/parquet/src/arrow/arrow_reader/mod.rs
+++ b/parquet/src/arrow/arrow_reader/mod.rs
@@ -1366,12 +1366,12 @@ pub struct ParquetRecordBatchReader {
 ///
 /// The first chunk keeps its [`BooleanBuffer`] without copying. A second chunk
 /// promotes the accumulator to a [`BooleanBufferBuilder`], and later chunks 
are
-/// appended to it. For example, chunks `1000` and `1` become `10001`:
+/// appended to it. For example, chunks `1001` and `1` become `10011`:
 ///
 /// ```text
-///           append(1000)             append(1)
+///           append(1001)             append(1)
 ///   Empty ───────────────▶ Single ───────────────▶ Combined
-///                          1000                    10001
+///                          1001                    10011
 ///                          (zero copy)             (promoted to builder)
 /// ```
 ///
@@ -1381,8 +1381,8 @@ pub struct ParquetRecordBatchReader {
 ///
 /// ```text
 ///   decoded rows:   0 1 2 3   11   <-- buffered by the array reader
-///   chunk masks:   [1 0 0 0] [1]
-///   finish():       1 0 0 0   1    <-- filters the whole batch in one pass
+///   chunk masks:   [1 0 0 1] [1]
+///   finish():       1 0 0 1   1    <-- filters the whole batch in one pass
 /// ```
 #[derive(Default)]
 enum FilterMaskAccumulator {
diff --git a/parquet/src/arrow/arrow_reader/read_plan.rs 
b/parquet/src/arrow/arrow_reader/read_plan.rs
index 88027ff543..f5b436ad0a 100644
--- a/parquet/src/arrow/arrow_reader/read_plan.rs
+++ b/parquet/src/arrow/arrow_reader/read_plan.rs
@@ -633,16 +633,16 @@ mod tests {
             panic!("expected a Mask cursor");
         };
 
-        // The first chunk must end at the loaded range boundary (row 4), not
-        // continue into the unloaded gap.
+        // The first chunk stops at its final selected row instead of carrying
+        // trailing skipped rows to the loaded range boundary.
         let first = cursor.next_chunk(12).unwrap();
         assert_eq!(first.initial_skip, 0);
-        assert_eq!(first.chunk_rows, 4);
+        assert_eq!(first.chunk_rows, 1);
         assert_eq!(first.selected_rows, 1);
 
-        // The second chunk skips the gap and decodes only within [10, 12).
+        // The second chunk skips directly to the next selected row.
         let second = cursor.next_chunk(12).unwrap();
-        assert_eq!(second.initial_skip, 7);
+        assert_eq!(second.initial_skip, 10);
         assert_eq!(second.chunk_rows, 1);
         assert_eq!(second.selected_rows, 1);
         assert!(cursor.is_empty());
diff --git a/parquet/src/arrow/arrow_reader/selection/cursor.rs 
b/parquet/src/arrow/arrow_reader/selection/cursor.rs
index 9a6caad24b..4d08197718 100644
--- a/parquet/src/arrow/arrow_reader/selection/cursor.rs
+++ b/parquet/src/arrow/arrow_reader/selection/cursor.rs
@@ -176,10 +176,11 @@ impl SelectorsCursor {
 /// LoadedRowRanges:  [0, 4)                         [10, 12)
 /// ```
 ///
-/// The first chunk decodes `[0, 4)` with mask `1000`. The next chunk skips to
-/// row 11 and decodes `[11, 12)` with mask `1`. The loaded ranges are decode
-/// boundaries, not output batch boundaries: [`ParquetRecordBatchReader`]
-/// accumulates both chunks and applies the combined mask `10001` once.
+/// The first chunk decodes `[0, 1)` with mask `1`. The next chunk skips to row
+/// 11 and decodes `[11, 12)` with mask `1`. When loaded ranges are present,
+/// every returned chunk ends at a selected row and never includes trailing
+/// unselected rows. [`ParquetRecordBatchReader`] still accumulates both chunks
+/// and applies the combined mask `11` once.
 ///
 /// [`ParquetRecordBatchReader`]: 
crate::arrow::arrow_reader::ParquetRecordBatchReader
 #[derive(Debug)]
@@ -254,6 +255,9 @@ impl MaskCursor {
     }
 
     /// Returns the next non-empty mask chunk without crossing an unloaded row 
range.
+    /// When loaded ranges are present, every returned chunk ends immediately 
after a
+    /// selected row and therefore never contains trailing unselected rows. 
Those rows
+    /// remain for the next call's initial skip.
     ///
     /// The [`ReadPlan`](crate::arrow::arrow_reader::ReadPlan) removes trailing
     /// skips before constructing this cursor. Callers therefore only invoke
@@ -272,10 +276,13 @@ impl MaskCursor {
             cursor += 1;
         }
 
-        debug_assert!(
-            cursor < self.mask.len(),
-            "ReadPlan must remove trailing skips from Mask selections"
-        );
+        if cursor == self.mask.len() {
+            return Err(ParquetError::General(
+                "Internal Error: Mask cursor reached the end without finding a 
selected row; \
+                 ReadPlan must remove trailing skips"
+                    .to_string(),
+            ));
+        }
 
         let loaded_range_end = self
             .loaded_row_ranges
@@ -289,17 +296,19 @@ impl MaskCursor {
 
         let mask_start = cursor;
         let mut selected_rows = 0;
+        let mut chunk_end = cursor;
         while cursor < loaded_range_end && cursor < self.mask.len() && 
selected_rows < batch_size {
             if self.mask.value(cursor) {
                 selected_rows += 1;
+                chunk_end = cursor + 1;
             }
             cursor += 1;
         }
 
-        self.position = cursor;
+        self.position = chunk_end;
         Ok(MaskChunk {
             initial_skip: mask_start - start_position,
-            chunk_rows: cursor - mask_start,
+            chunk_rows: chunk_end - mask_start,
             selected_rows,
             mask_start,
         })
diff --git a/parquet/src/arrow/push_decoder/reader_builder/mod.rs 
b/parquet/src/arrow/push_decoder/reader_builder/mod.rs
index 84ac8259d4..89b6d58fbb 100644
--- a/parquet/src/arrow/push_decoder/reader_builder/mod.rs
+++ b/parquet/src/arrow/push_decoder/reader_builder/mod.rs
@@ -554,7 +554,9 @@ impl RowGroupReaderBuilder {
                     predicate.projection(), // use the predicate's projection
                 )
                 .with_selection(plan_builder.selection())
-                // Fetch predicate columns; expand selection only for cached 
predicate columns
+                // Cached output columns reuse these predicate-stage chunks. 
Expand their
+                // selection to cache batch boundaries so a cache miss can 
safely fetch a
+                // complete batch from the retained sparse column data.
                 .with_cache_projection(Some(filter_info.cache_projection()))
                 .with_column_chunks(column_chunks)
                 .build();
diff --git a/parquet/tests/arrow_reader/row_filter/async.rs 
b/parquet/tests/arrow_reader/row_filter/async.rs
index a590e38107..2e2c0b6ea4 100644
--- a/parquet/tests/arrow_reader/row_filter/async.rs
+++ b/parquet/tests/arrow_reader/row_filter/async.rs
@@ -35,7 +35,7 @@ use parquet::{
         ArrowWriter, ParquetRecordBatchStreamBuilder, ProjectionMask,
         arrow_reader::{
             ArrowPredicateFn, ArrowReaderOptions, RowFilter, RowSelection, 
RowSelectionPolicy,
-            RowSelector,
+            RowSelector, metrics::ArrowReaderMetrics,
         },
     },
     file::{
@@ -169,6 +169,74 @@ async fn test_row_filter_full_page_skip_is_handled_async() 
{
     }
 }
 
+#[tokio::test]
+async fn test_cached_mask_reads_sparse_pages_without_error() {
+    let values = (0..60).collect::<Vec<i64>>();
+    let data = make_two_column_i64_file(&values, 20);
+
+    for policy in [
+        RowSelectionPolicy::Auto { threshold: 32 },
+        RowSelectionPolicy::Mask,
+    ] {
+        let metrics = ArrowReaderMetrics::enabled();
+        let builder = ParquetRecordBatchStreamBuilder::new_with_options(
+            TestReader::new(data.clone()),
+            
ArrowReaderOptions::new().with_page_index_policy(PageIndexPolicy::Required),
+        )
+        .await
+        .unwrap();
+        let schema = builder.parquet_schema().clone();
+        let projection = ProjectionMask::leaves(&schema, [0]);
+        let page_first_rows = builder.metadata().offset_index().unwrap()[0][0]
+            .page_locations()
+            .iter()
+            .map(|page| page.first_row_index)
+            .collect::<Vec<_>>();
+        assert_eq!(page_first_rows, vec![0, 20, 40]);
+
+        let predicate = ArrowPredicateFn::new(projection.clone(), |batch: 
RecordBatch| {
+            Ok(BooleanArray::from(vec![true; batch.num_rows()]))
+        });
+        // Extending the first mask chunk to the 20-row page boundary would 
make
+        // the 8-row cache batch at rows 16..24 cross into the unloaded middle 
page.
+        let stream = builder
+            .with_projection(projection)
+            .with_row_filter(RowFilter::new(vec![Box::new(predicate)]))
+            .with_row_selection(RowSelection::from(vec![
+                RowSelector::select(1),
+                RowSelector::skip(39),
+                RowSelector::select(1),
+            ]))
+            .with_batch_size(8)
+            .with_max_predicate_cache_size(1024)
+            .with_row_selection_policy(policy)
+            .with_metrics(metrics.clone())
+            .build()
+            .unwrap();
+
+        let output_schema = stream.schema().clone();
+        let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
+        let output = concat_batches(&output_schema, &batches).unwrap();
+        assert_eq!(
+            output
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap()
+                .values(),
+            &[0, 40],
+            "policy={policy:?}"
+        );
+        assert!(
+            metrics
+                .records_read_from_cache()
+                .expect("metrics are enabled")
+                > 0,
+            "predicate cache was not exercised for policy={policy:?}"
+        );
+    }
+}
+
 #[tokio::test]
 async fn test_mask_coalesces_loaded_ranges_to_batch_size() {
     let values = (0..12).collect::<Vec<i64>>();

Reply via email to