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

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


The following commit(s) were added to refs/heads/main by this push:
     new b0f89adf feat(reader): merge-aware pushdown gate, row-group pruning, 
and read-volume counters (#708)
b0f89adf is described below

commit b0f89adf55a299845362b765eea641f7b4bf6f42
Author: Lin Liu <[email protected]>
AuthorDate: Wed Sep 2 17:52:39 2026 -0700

    feat(reader): merge-aware pushdown gate, row-group pruning, and read-volume 
counters (#708)
    
    Co-authored-by: Y Ethan Guo <[email protected]>
---
 crates/core/src/file_group/base_file/parquet.rs    | 198 +++++++++-
 crates/core/src/file_group/base_file/reader.rs     |  41 ++-
 crates/core/src/file_group/reader_v2/engine.rs     | 403 +++++++++++++++++++--
 crates/core/src/file_group/reader_v2/harness.rs    |  31 +-
 .../core/src/file_group/reader_v2/harness_tests.rs |  57 +--
 .../src/file_group/reader_v2/log_record_reader.rs  |  15 +-
 .../src/file_group/reader_v2/reader_context.rs     |  89 +----
 crates/core/src/file_group/reader_v2/resolver.rs   |   4 +-
 crates/core/src/storage/mod.rs                     | 131 +++++++
 9 files changed, 820 insertions(+), 149 deletions(-)

diff --git a/crates/core/src/file_group/base_file/parquet.rs 
b/crates/core/src/file_group/base_file/parquet.rs
index 56c9be2f..c2b5e494 100644
--- a/crates/core/src/file_group/base_file/parquet.rs
+++ b/crates/core/src/file_group/base_file/parquet.rs
@@ -31,11 +31,65 @@ use parquet::file::metadata::ParquetMetaData;
 use super::reader::{BaseFileReadOptions, BaseFileReader, BaseFileStream};
 use crate::schema::parquet_list_norm::normalize_parquet_metadata;
 use crate::statistics::StatisticsContainer;
+use crate::storage::ReadVolume;
 use crate::storage::Storage;
 use crate::storage::error::{Result, StorageError};
 use crate::storage::file_metadata::FileMetadata;
 use crate::storage::util::join_url_segments;
 
+/// `AsyncFileReader` wrapper that accumulates read volume into a shared
+/// [`ReadVolume`].
+///
+/// Delegates everything and changes no behaviour. Counting happens here rather
+/// than inside the object store because the store is shared between readers 
and
+/// therefore cannot carry per-read counters.
+struct CountingReader<R: AsyncFileReader> {
+    inner: R,
+    volume: Arc<ReadVolume>,
+}
+
+impl<R: AsyncFileReader> AsyncFileReader for CountingReader<R> {
+    fn get_bytes(
+        &mut self,
+        range: std::ops::Range<u64>,
+    ) -> BoxFuture<'_, parquet::errors::Result<bytes::Bytes>> {
+        let volume = self.volume.clone();
+        let fut = self.inner.get_bytes(range);
+        Box::pin(async move {
+            let bytes = fut.await?;
+            volume.add_bytes(bytes.len() as u64);
+            Ok(bytes)
+        })
+    }
+
+    fn get_byte_ranges(
+        &mut self,
+        ranges: Vec<std::ops::Range<u64>>,
+    ) -> BoxFuture<'_, parquet::errors::Result<Vec<bytes::Bytes>>> {
+        let volume = self.volume.clone();
+        let fut = self.inner.get_byte_ranges(ranges);
+        Box::pin(async move {
+            let chunks = fut.await?;
+            // One call, many ranges: count the call once and every byte it
+            // returned, so `io_calls` stays a count of round trips.
+            let total: u64 = chunks.iter().map(|b| b.len() as u64).sum();
+            volume.add_bytes(total);
+            Ok(chunks)
+        })
+    }
+
+    fn get_metadata<'a>(
+        &'a mut self,
+        options: Option<&'a ArrowReaderOptions>,
+    ) -> BoxFuture<'a, parquet::errors::Result<Arc<ParquetMetaData>>> {
+        self.inner.get_metadata(options)
+    }
+}
+
+/// The builder every read here is driven from: a parquet object reader with 
the
+/// read-volume counters wrapped around it.
+type CountedBuilder = 
ParquetRecordBatchStreamBuilder<CountingReader<ParquetObjectReader>>;
+
 /// Parquet implementation of [`BaseFileReader`].
 ///
 /// Reads Parquet files directly via `object_store` and the `parquet` crate.
@@ -78,7 +132,7 @@ impl ParquetBaseFileReader {
         obj_path: ObjPath,
         file_size: u64,
         row_index_column: Option<&str>,
-    ) -> Result<ParquetRecordBatchStreamBuilder<ParquetObjectReader>> {
+    ) -> Result<CountedBuilder> {
         let mut reader = 
ParquetObjectReader::new(self.storage.object_store.clone(), obj_path)
             .with_file_size(file_size);
 
@@ -94,6 +148,11 @@ impl ParquetBaseFileReader {
             normalized,
             Self::arrow_reader_options(row_index_column)?,
         )?;
+
+        let reader = CountingReader {
+            inner: reader,
+            volume: self.storage.read_volume.clone(),
+        };
         Ok(ParquetRecordBatchStreamBuilder::new_with_metadata(
             reader,
             arrow_metadata,
@@ -104,16 +163,28 @@ impl ParquetBaseFileReader {
         &self,
         relative_path: &str,
         row_index_column: Option<&str>,
-    ) -> Result<ParquetRecordBatchStreamBuilder<ParquetObjectReader>> {
+    ) -> Result<CountedBuilder> {
         let (obj_path, file_size) = 
self.object_path_and_size(relative_path).await?;
         self.open_builder_with_size(obj_path, file_size, row_index_column)
             .await
     }
 
     fn apply_options(
-        mut builder: ParquetRecordBatchStreamBuilder<ParquetObjectReader>,
+        &self,
+        mut builder: CountedBuilder,
         options: &BaseFileReadOptions,
-    ) -> Result<ParquetRecordBatchStreamBuilder<ParquetObjectReader>> {
+    ) -> Result<CountedBuilder> {
+        // What the file holds, taken from the footer that has already been
+        // fetched, so it costs no extra IO. Recorded here rather than at every
+        // builder open so it counts once per read OF THE DATA: opening the 
same
+        // file again for its schema alone would otherwise inflate the
+        // denominator that `row_groups_read` and `rows_out` are read against.
+        let metadata = builder.metadata();
+        self.storage.read_volume.record_file_shape(
+            metadata.num_row_groups() as u64,
+            metadata.file_metadata().num_rows().max(0) as u64,
+        );
+
         if let Some(batch_size) = options.batch_size {
             builder = builder.with_batch_size(batch_size);
         }
@@ -150,6 +221,34 @@ impl ParquetBaseFileReader {
             builder = builder.with_projection(projection_mask);
         }
 
+        // Prune row groups from footer statistics, BEFORE the row filter is
+        // installed: a group excluded here is never fetched, so the filter 
only
+        // ever sees groups that survived.
+        let volume = &self.storage.read_volume;
+        let total_row_groups = builder.metadata().num_row_groups();
+        let mut row_groups_read = total_row_groups;
+        if let Some(keep) = 
options.row_group_selector.as_ref().and_then(|select| {
+            // Count the CALL, not just a successful prune, so "ran and found
+            // nothing" stays distinguishable from "never installed".
+            volume.record_selector_call();
+            select(builder.metadata())
+        }) {
+            // A selector that names a row group the file does not have is a 
bug
+            // in the selector. parquet-rs does not validate the indices; the
+            // read panics when the stream reaches the bad index, release 
builds
+            // included. The `debug_assert!` surfaces the bug at this choke
+            // point with a clear message in tests; no release check is added
+            // because the failure is already loud, a panic rather than lost
+            // rows.
+            debug_assert!(
+                keep.iter().all(|&i| i < total_row_groups),
+                "selector returned an out-of-range row-group index"
+            );
+            row_groups_read = keep.len();
+            builder = builder.with_row_groups(keep);
+        }
+        volume.add_row_groups_read(row_groups_read as u64);
+
         // Built here rather than by the caller because the predicate has to be
         // resolved against the file's own schema, which only exists once the
         // footer is open. A builder that returns `None` — typically because 
the
@@ -229,7 +328,7 @@ impl BaseFileReader for ParquetBaseFileReader {
             let builder = self
                 .open_builder(relative_path, 
options.row_index_column.as_deref())
                 .await?;
-            let builder = Self::apply_options(builder, &options)?;
+            let builder = self.apply_options(builder, &options)?;
             let full_schema = builder.schema().clone();
             let stream = builder.build()?;
             let schema = Self::schema_with_row_index(
@@ -237,14 +336,31 @@ impl BaseFileReader for ParquetBaseFileReader {
                 &full_schema,
                 options.row_index_column.as_deref(),
             )?;
+            // Rows the stream actually yields — after any row filter. Against
+            // `file_rows` this is the read's selectivity; against 
`bytes_read`,
+            // what that selectivity cost.
+            let volume = self.storage.read_volume.clone();
             let mapped_stream = stream
-                .map(|result| result.map_err(StorageError::from))
+                .map(move |result| {
+                    let batch = result.map_err(StorageError::from)?;
+                    volume.add_rows_out(batch.num_rows() as u64);
+                    Ok(batch)
+                })
                 .boxed();
 
             Ok(BaseFileStream::new(schema, mapped_stream))
         })
     }
 
+    /// Answered from the footer alone: no stream is built, and no read-volume
+    /// counter moves for a call that reads no data.
+    fn read_schema<'a>(
+        &'a self,
+        relative_path: &'a str,
+    ) -> BoxFuture<'a, Result<arrow_schema::SchemaRef>> {
+        Box::pin(async move { 
Ok(Arc::new(self.get_schema(relative_path).await?)) })
+    }
+
     fn get_metadata_and_stats<'a>(
         &'a self,
         relative_path: &'a str,
@@ -379,6 +495,76 @@ mod tests {
         assert_eq!(batch.num_rows(), 0, "the predicate rejected every row");
     }
 
+    /// The volume counters describe a real read: what the file held, what was
+    /// fetched to read it, and what came back.
+    #[tokio::test]
+    async fn read_volume_counts_what_the_read_actually_moved() {
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let storage = test_storage();
+        let volume = storage.read_volume();
+        let reader = ParquetBaseFileReader::new(storage);
+
+        let batch = reader
+            .read_data("a.parquet", BaseFileReadOptions::default())
+            .await
+            .unwrap();
+
+        assert_eq!(batch.num_rows(), 5);
+        assert_eq!(volume.file_rows.load(Relaxed), 5, "footer row count");
+        assert_eq!(volume.rows_out.load(Relaxed), 5, "every row was yielded");
+        let file_row_groups = volume.file_row_groups.load(Relaxed);
+        assert!(file_row_groups >= 1, "the file has at least one row group");
+        assert_eq!(
+            volume.row_groups_read.load(Relaxed),
+            file_row_groups,
+            "nothing prunes, so every row group is scanned"
+        );
+        assert!(volume.bytes_read.load(Relaxed) > 0, "bytes were fetched");
+        assert!(volume.io_calls.load(Relaxed) > 0, "round trips were made");
+    }
+
+    /// Why the counters exist. A `RowFilter` decides per row *after* the
+    /// predicate columns are decoded, so a filter that rejects everything 
still
+    /// reads the file: `rows_out` collapses to zero while `bytes_read` does 
not,
+    /// and `row_groups_read` still covers the whole file. A disposition flag
+    /// ("was a predicate pushed?") reports the same thing here as it would 
for a
+    /// read that skipped the file entirely.
+    #[tokio::test]
+    async fn a_row_filter_that_rejects_everything_still_reads_the_file() {
+        use arrow_array::BooleanArray;
+        use parquet::arrow::ProjectionMask;
+        use parquet::arrow::arrow_reader::{ArrowPredicateFn, RowFilter};
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let storage = test_storage();
+        let volume = storage.read_volume();
+        let reader = ParquetBaseFileReader::new(storage);
+
+        let opts = 
BaseFileReadOptions::default().with_row_filter(Arc::new(|descr, _| {
+            let mask = ProjectionMask::roots(descr, [0]);
+            Some(RowFilter::new(vec![Box::new(ArrowPredicateFn::new(
+                mask,
+                |batch| Ok(BooleanArray::from(vec![false; batch.num_rows()])),
+            ))]))
+        }));
+
+        let batch = reader.read_data("a.parquet", opts).await.unwrap();
+
+        assert_eq!(batch.num_rows(), 0);
+        assert_eq!(volume.rows_out.load(Relaxed), 0, "no row survived");
+        assert_eq!(volume.file_rows.load(Relaxed), 5, "the file still held 5");
+        assert_eq!(
+            volume.row_groups_read.load(Relaxed),
+            volume.file_row_groups.load(Relaxed),
+            "a row filter skips no row group"
+        );
+        assert!(
+            volume.bytes_read.load(Relaxed) > 0,
+            "rejecting every row still cost IO"
+        );
+    }
+
     /// A builder that declines — typically because the file has none of the
     /// columns the predicate names — reads every row. Returning no rows would
     /// silently drop data on a file the predicate cannot speak about.
diff --git a/crates/core/src/file_group/base_file/reader.rs 
b/crates/core/src/file_group/base_file/reader.rs
index d9a9c519..c062ed2a 100644
--- a/crates/core/src/file_group/base_file/reader.rs
+++ b/crates/core/src/file_group/base_file/reader.rs
@@ -31,7 +31,7 @@ use crate::config::table::BaseFileFormatValue;
 use crate::statistics::StatisticsContainer;
 use crate::storage::error::Result;
 use crate::storage::file_metadata::FileMetadata;
-use crate::storage::{RowFilterBuilder, Storage};
+use crate::storage::{RowFilterBuilder, RowGroupSelector, Storage};
 
 /// Which record keys a read is interested in.
 ///
@@ -130,6 +130,18 @@ pub struct BaseFileReadOptions {
     /// excluded from [`projection`](Self::projection) matching, since the 
column
     /// is not one of the file's own.
     pub row_index_column: Option<String>,
+    /// Picks which row groups to read, from the file's footer statistics.
+    ///
+    /// Only the Parquet reader honors this; other formats ignore it. Unlike
+    /// [`row_filter`](Self::row_filter), which discards rows once they have 
been
+    /// decoded, a row group this excludes is never fetched — so the two are
+    /// complementary, and this is the one that saves IO. It runs against the
+    /// footer the reader has already read, so consulting it costs nothing.
+    ///
+    /// It carries the same safety obligation as `row_filter`, and a stronger
+    /// one: pruning removes base rows before a log merge could have updated 
them
+    /// into a match. The caller installs it only when that cannot happen.
+    pub row_group_selector: Option<RowGroupSelector>,
 }
 
 // `row_filter` holds a closure, which has no `Debug`. Report whether one is 
set
@@ -143,6 +155,7 @@ impl std::fmt::Debug for BaseFileReadOptions {
             .field("key_predicate", &self.key_predicate)
             .field("row_filter", &self.row_filter.is_some())
             .field("row_index_column", &self.row_index_column)
+            .field("row_group_selector", &self.row_group_selector.is_some())
             .finish()
     }
 }
@@ -158,6 +171,13 @@ impl BaseFileReadOptions {
         self
     }
 
+    /// Read only the row groups a selector keeps. See
+    /// [`Self::row_group_selector`].
+    pub fn with_row_group_selector(mut self, selector: RowGroupSelector) -> 
Self {
+        self.row_group_selector = Some(selector);
+        self
+    }
+
     /// Read only the records a key predicate admits, where the format can seek
     /// by key. See [`Self::key_predicate`].
     pub fn with_key_predicate(mut self, predicate: KeyPredicate) -> Self {
@@ -253,6 +273,25 @@ pub trait BaseFileReader: Send + Sync {
         })
     }
 
+    /// The schema of a base file, without reading its data.
+    ///
+    /// The default opens a stream and takes the schema it reports, which is 
what
+    /// a caller would otherwise write by hand. A format that can answer from
+    /// metadata alone should override it: the caller wants the schema in 
order to
+    /// decide what to read next, so the read that follows is a second open, 
and
+    /// only the override keeps this one from setting up a decode nobody polls.
+    fn read_schema<'a>(
+        &'a self,
+        relative_path: &'a str,
+    ) -> BoxFuture<'a, Result<arrow_schema::SchemaRef>> {
+        Box::pin(async move {
+            let stream = self
+                .read_stream(relative_path, BaseFileReadOptions::new())
+                .await?;
+            Ok(stream.schema().clone())
+        })
+    }
+
     /// Read data from a base file as a stream of RecordBatches.
     fn read_stream<'a>(
         &'a self,
diff --git a/crates/core/src/file_group/reader_v2/engine.rs 
b/crates/core/src/file_group/reader_v2/engine.rs
index 3a4d8e28..f5f089a3 100644
--- a/crates/core/src/file_group/reader_v2/engine.rs
+++ b/crates/core/src/file_group/reader_v2/engine.rs
@@ -46,7 +46,7 @@ use crate::file_group::reader_v2::read_stats::HoodieReadStats;
 use crate::file_group::reader_v2::reader_context::ReaderContext;
 use crate::file_group::reader_v2::reader_parameters::ReaderParameters;
 use crate::file_group::reader_v2::schema_handler::FileGroupReaderSchemaHandler;
-use crate::storage::{RowFilterBuilder, Storage};
+use crate::storage::{RowFilterBuilder, RowGroupSelector, Storage};
 use arrow_array::RecordBatch;
 use arrow_schema::SchemaRef;
 use futures::StreamExt;
@@ -164,6 +164,7 @@ const MERGE_CHUNK_ROWS: usize = 1024;
 /// not the read that dropped it.
 fn base_read_options(
     row_filter: Option<RowFilterBuilder>,
+    row_group_selector: Option<RowGroupSelector>,
     key_predicate: Option<crate::file_group::base_file::reader::KeyPredicate>,
     use_record_position: bool,
 ) -> BaseFileReadOptions {
@@ -172,6 +173,9 @@ fn base_read_options(
     if let Some(row_filter) = row_filter {
         options = options.with_row_filter(row_filter);
     }
+    if let Some(row_group_selector) = row_group_selector {
+        options = options.with_row_group_selector(row_group_selector);
+    }
     if let Some(key_predicate) = key_predicate {
         options = options.with_key_predicate(key_predicate);
     }
@@ -659,6 +663,64 @@ impl HoodieFileGroupReader {
         ))
     }
 
+    /// Is it safe to push the predicate into the BASE read of this split?
+    ///
+    /// Safe exactly when no log merge can change the predicate's outcome. Two
+    /// ways that holds:
+    ///   - the split carries no log files, so nothing merges and the base rows
+    ///     are final; or
+    ///   - the predicate references only record-key columns, which are 
immutable
+    ///     across upserts, so the outcome survives the merge (`mor_pk_safe`).
+    ///
+    /// Mirrors Java's `SparkFileFormatInternalRowReaderContext
+    /// .getSchemaAndFiltersForRead`, which branches on `getHasLogFiles()` and
+    /// never on the table type: `allFilters` when there are no log files,
+    /// `morFilters` when there are. The table type does not appear here 
either —
+    /// a CoW slice has no log files, so it takes the first branch on its own.
+    ///
+    /// # Why the split and not `ReaderContext`
+    ///
+    /// [`ReaderContext::has_log_files`] is a different fact with a different
+    /// source: it is set by whoever built the context, whereas
+    /// [`InputSplit::log_file_paths`] is the split's own file list. Nothing
+    /// derives one from the other, so gating on the context flag would rest a
+    /// safety decision on a caller-supplied boolean. If it were ever false 
for a
+    /// slice that does have logs, a non-PK predicate would reach the base read
+    /// and drop rows the merge would have updated — silently, because a filter
+    /// above the reader can only remove rows, never restore them.
+    ///
+    /// Using the split also keeps this in lock-step with
+    /// [`Self::use_record_position`], which reads the same
+    /// `input_split.has_log_files()`. The gate and the merge therefore cannot
+    /// disagree about a split.
+    ///
+    /// # Why there is no bootstrap term, unlike Java
+    ///
+    /// Java has a third branch — `!getHasLogFiles && hasRowIndexField` selects
+    /// `bootstrapSafeFilters` — because a bootstrap read pairs skeleton and 
data
+    /// files by row position, and filters that physically drop records 
misalign
+    /// that pairing. `has_bootstrap_base_file` reaches `ReaderContext` here 
and
+    /// is consulted nowhere, so a bootstrap slice with
+    /// `needs_bootstrap_merge == false` does arrive at this gate. It is still
+    /// safe, for two independent reasons:
+    ///
+    /// 1. The positional mechanism here is the virtual `RowNumber` column, 
which
+    ///    carries each row's TRUE physical position and stays correct under
+    ///    row-group selection and `RowFilter` pushdown. Dropping rows cannot
+    ///    shift it, which is exactly the failure Java's tier avoids by not
+    ///    pushing.
+    /// 2. The row-index column is requested only from
+    ///    [`Self::use_record_position`], which returns false when the split 
has
+    ///    no log files — so on the branch this gate widens, there is no
+    ///    positional pairing to misalign at all.
+    ///
+    /// Anyone adding a bootstrap term should re-check both: lifting the
+    /// `needs_bootstrap_merge` rejection in `new()` without revisiting this 
gate
+    /// is how the Java hazard would arrive here.
+    fn base_read_pushdown_is_safe(&self) -> bool {
+        !self.input_split.has_log_files() || self.reader_context.mor_pk_safe
+    }
+
     /// Whether this read should merge base + log records by base-file row
     /// position (rather than by record key). Mirrors Java
     /// `HoodieFileGroupReader`'s `setShouldMergeUseRecordPosition`:
@@ -734,26 +796,52 @@ impl HoodieFileGroupReader {
             );
         }
 
-        // gate parquet RowFilter pushdown.
-        //   CoW: always safe (no merge).
-        //   MOR: safe ONLY when every column referenced by the filter is a
-        //        primary key (PKs are immutable across upserts, so the 
predicate
-        //        outcome doesn't change post-merge — 
`reader_context.mor_pk_safe`,
-        //        mirroring Java's `filterIsSafeForPrimaryKey`).
+        // gate parquet RowFilter pushdown on whether this read MERGES.
+        //   No log files on this split: always safe (nothing merges). A CoW
+        //        slice reaches the gate this way.
+        //   Log files present: safe ONLY when every column referenced by the
+        //        filter is a primary key (PKs are immutable across upserts, so
+        //        the predicate outcome doesn't change post-merge —
+        //        `reader_context.mor_pk_safe`, mirroring Java's
+        //        `filterIsSafeForPrimaryKey`).
         //   Otherwise: drop the filter; the post-merge filter (Velox/Spark 
above
         //        the FG reader) evaluates the predicate after base+log merge.
-        let row_filter = if self.reader_context.can_push_row_filter() {
+        // ONE gate, bound once and shared by both mechanisms. Bound to a local
+        // rather than called twice so the sharing is structural: an edit that
+        // changes the condition for one can no longer leave the other behind,
+        // and pruning is the one that must not be left behind — it drops rows
+        // before the merge can see them.
+        let pushdown_is_safe = self.base_read_pushdown_is_safe();
+        let row_filter = if pushdown_is_safe {
             self.reader_context.row_filter_builder.clone()
         } else {
             if self.reader_context.row_filter_builder.is_some() {
                 log::debug!(
-                    "MOR + non-PK predicate — skipping parquet \
+                    "merging read with a non-PK predicate — skipping parquet \
                      RowFilter pushdown for base file '{path}' \
                      (post-merge filter still runs)"
                 );
             }
             None
         };
+        let row_group_selector = if pushdown_is_safe {
+            self.reader_context.row_group_selector.clone()
+        } else {
+            // Record the suppression. The gate and the selector are each 
correct
+            // alone; what does not compose is the observability.
+            // `row_group_selector_calls` exists to separate "ran and found
+            // nothing" from "never installed", and a selector the gate refuses
+            // is a third state that also reads zero calls. Counting it here
+            // keeps that counter answerable.
+            if self.reader_context.row_group_selector.is_some() {
+                self.storage.read_volume().record_selector_suppressed();
+                log::debug!(
+                    "merging read with a non-PK predicate — skipping row-group 
\
+                     pruning for base file '{path}' (post-merge filter still 
runs)"
+                );
+            }
+            None
+        };
 
         // The key predicate needs no such gate. It narrows *which blocks are 
read*
         // and the reader filters the records it brings back, so it cannot 
change the
@@ -780,7 +868,12 @@ impl HoodieFileGroupReader {
                 .base_file_reader()?
                 .read_data(
                     &path,
-                    base_read_options(row_filter.clone(), 
key_predicate.clone(), use_position),
+                    base_read_options(
+                        row_filter.clone(),
+                        row_group_selector.clone(),
+                        key_predicate.clone(),
+                        use_position,
+                    ),
                 )
                 .await
                 .map_err(|e| {
@@ -808,9 +901,8 @@ impl HoodieFileGroupReader {
         // interleaves is already in `required_schema`.
         let file_schema = self
             .base_file_reader()?
-            .read_stream(&path, BaseFileReadOptions::new())
+            .read_schema(&path)
             .await
-            .map(|s| s.schema().clone())
             .map_err(|e| {
                 CoreError::ReadFileSliceError(format!(
                     "Failed to read base file footer schema '{path}': {e:?}"
@@ -861,16 +953,22 @@ impl HoodieFileGroupReader {
         }
 
         // Open the base file as a stream. The whole file never lives in 
memory;
-        // one batch does. The (CoW-gated) RowFilter is threaded through the
-        // intersection read so row groups can be pruned via column-index stats
-        // (the builder resolves predicate columns by name and returns None 
when
-        // any referenced column is absent — safe even for evolved/added cols).
+        // one batch does. The gated RowFilter and row-group selector are both
+        // threaded through the intersection read. Only the SELECTOR skips IO: 
a
+        // RowFilter decides per row once the predicate columns are decoded. 
The
+        // filter builder resolves predicate columns by name and returns None 
when
+        // any referenced column is absent — safe even for evolved/added cols.
         let base_stream = self
             .base_file_reader()?
             .read_stream(
                 &path,
-                base_read_options(row_filter.clone(), key_predicate.clone(), 
use_position)
-                    .with_projection(intersection.fields().iter().map(|f| 
f.name())),
+                base_read_options(
+                    row_filter.clone(),
+                    row_group_selector.clone(),
+                    key_predicate.clone(),
+                    use_position,
+                )
+                .with_projection(intersection.fields().iter().map(|f| 
f.name())),
             )
             .await
             .map_err(|e| {
@@ -1084,6 +1182,9 @@ pub struct HoodieFileGroupReaderBuilder {
     /// at build time so the same builder is visible to base parquet reads
     /// (this file) and parquet log block decodes (`log_file::content`).
     row_filter_builder: Option<RowFilterBuilder>,
+    /// Set by `with_row_group_selector`; copied onto a cloned reader_context 
in
+    /// `build()`, exactly like `row_filter_builder`.
+    row_group_selector: Option<RowGroupSelector>,
     /// Set by `with_mor_pk_safe`; copied onto the cloned reader_context.
     mor_pk_safe: Option<bool>,
 }
@@ -1128,7 +1229,7 @@ impl HoodieFileGroupReaderBuilder {
     /// install a parquet `RowFilter` builder.
     ///
     /// Whether the builder is actually used at scan time is gated by
-    /// `reader_context.can_push_row_filter()`:
+    /// `base_read_pushdown_is_safe()`:
     /// - CoW table → always pushed
     /// - MOR table → pushed only if `mor_pk_safe` is true (see
     ///   [`Self::with_mor_pk_safe`])
@@ -1140,6 +1241,18 @@ impl HoodieFileGroupReaderBuilder {
         self
     }
 
+    /// Install a row-group selector, pruning base reads from footer 
statistics.
+    ///
+    /// Routed onto `reader_context` exactly like
+    /// [`Self::with_row_filter_builder`], and gated at scan time by the same
+    /// `base_read_pushdown_is_safe()`. Setting one without the other is
+    /// supported: they are independent mechanisms over the same predicate, and
+    /// only this one avoids IO.
+    pub fn with_row_group_selector(mut self, selector: RowGroupSelector) -> 
Self {
+        self.row_group_selector = Some(selector);
+        self
+    }
+
     /// mark the pushed predicate as safe for MOR (i.e. it
     /// references only primary-key columns). When true, the row filter
     /// pushes into both base parquet files and parquet log blocks on MOR
@@ -1167,11 +1280,17 @@ impl HoodieFileGroupReaderBuilder {
         // builder API, copy them onto the reader_context. Clone-and-replace
         // mirrors the same pattern HoodieFileGroupReader::new() uses to
         // update the schema_handler on its reader_context.
-        let reader_context = if self.row_filter_builder.is_some() || 
self.mor_pk_safe.is_some() {
+        let reader_context = if self.row_filter_builder.is_some()
+            || self.row_group_selector.is_some()
+            || self.mor_pk_safe.is_some()
+        {
             let mut updated = (*reader_context).clone();
             if let Some(b) = self.row_filter_builder {
                 updated.row_filter_builder = Some(b);
             }
+            if let Some(selector) = self.row_group_selector {
+                updated.row_group_selector = Some(selector);
+            }
             if let Some(s) = self.mor_pk_safe {
                 updated.mor_pk_safe = s;
             }
@@ -1583,6 +1702,19 @@ mod tests {
         InputSplit::new(None, None, vec![], "p1".to_string())
     }
 
+    /// A split that merges: one base file and one log file. The gate reduces 
to
+    /// `mor_pk_safe` only on a split like this — with no log files it is open
+    /// whatever `mor_pk_safe` says, so a PK-safety assertion made on a bare
+    /// split would pass without testing anything.
+    fn merging_input_split() -> InputSplit {
+        InputSplit::new(
+            Some("base.parquet".to_string()),
+            None,
+            vec![".log.1".to_string()],
+            "p1".to_string(),
+        )
+    }
+
     fn make_row_filter_builder() -> RowFilterBuilder {
         // Closure that always returns None — we only care that the builder
         // was installed, not what it produces.
@@ -1611,14 +1743,14 @@ mod tests {
         let reader = HoodieFileGroupReader::builder()
             .with_reader_context(dummy_reader_context("MERGE_ON_READ"))
             .with_storage(storage)
-            .with_input_split(dummy_input_split())
+            .with_input_split(merging_input_split())
             .with_row_filter_builder(make_row_filter_builder())
             .with_mor_pk_safe(true)
             .build()
             .unwrap();
         assert!(reader.reader_context.mor_pk_safe);
         assert!(
-            reader.reader_context.can_push_row_filter(),
+            reader.base_read_pushdown_is_safe(),
             "MOR + mor_pk_safe=true must push"
         );
     }
@@ -1629,18 +1761,235 @@ mod tests {
         let reader = HoodieFileGroupReader::builder()
             .with_reader_context(dummy_reader_context("MERGE_ON_READ"))
             .with_storage(storage)
-            .with_input_split(dummy_input_split())
+            .with_input_split(merging_input_split())
             .with_row_filter_builder(make_row_filter_builder())
             // mor_pk_safe defaults to false
             .build()
             .unwrap();
         assert!(!reader.reader_context.mor_pk_safe);
         assert!(
-            !reader.reader_context.can_push_row_filter(),
+            !reader.base_read_pushdown_is_safe(),
             "MOR without PK-safety must NOT push (mirrors Java's morFilters 
gate)"
         );
     }
 
+    /// A MOR slice with no log files does not merge, so the predicate is safe 
to
+    /// push whatever `mor_pk_safe` says. Parameterized over both values so the
+    /// "does it merge" rule is shown to be independent of PK safety.
+    #[test]
+    fn base_only_mor_slice_allows_pushdown_regardless_of_pk_safety() {
+        for mor_pk_safe in [false, true] {
+            let storage = 
Storage::new_with_base_url(parse_uri("file:///tmp").unwrap()).unwrap();
+            let reader = HoodieFileGroupReader::builder()
+                .with_reader_context(dummy_reader_context("MERGE_ON_READ"))
+                .with_storage(storage)
+                .with_input_split(InputSplit::new(
+                    Some("base.parquet".to_string()),
+                    None,
+                    // No log files => no merge => nothing can flip the 
predicate.
+                    vec![],
+                    "p1".to_string(),
+                ))
+                .with_row_filter_builder(make_row_filter_builder())
+                .with_mor_pk_safe(mor_pk_safe)
+                .build()
+                .unwrap();
+            assert!(
+                reader.base_read_pushdown_is_safe(),
+                "base-only slice must push regardless of mor_pk_safe 
({mor_pk_safe})"
+            );
+        }
+    }
+
+    /// The split rule must not weaken the real MOR case: with log files 
present
+    /// the merge can supersede or delete a base row, so a non-PK-safe 
predicate
+    /// still may not be pushed.
+    #[test]
+    fn mor_slice_with_log_files_still_blocks_pushdown_when_not_pk_safe() {
+        let storage = 
Storage::new_with_base_url(parse_uri("file:///tmp").unwrap()).unwrap();
+        let reader = HoodieFileGroupReader::builder()
+            .with_reader_context(dummy_reader_context("MERGE_ON_READ"))
+            .with_storage(storage)
+            .with_input_split(InputSplit::new(
+                Some("base.parquet".to_string()),
+                None,
+                vec![".log.1".to_string()],
+                "p1".to_string(),
+            ))
+            .with_row_filter_builder(make_row_filter_builder())
+            // mor_pk_safe defaults to false
+            .build()
+            .unwrap();
+        assert!(reader.input_split.has_log_files());
+        assert!(
+            !reader.base_read_pushdown_is_safe(),
+            "MOR with log files and no PK safety must NOT push"
+        );
+    }
+
+    #[test]
+    fn builder_routes_row_group_selector_into_reader_context() {
+        let storage = 
Storage::new_with_base_url(parse_uri("file:///tmp").unwrap()).unwrap();
+        let reader = HoodieFileGroupReader::builder()
+            .with_reader_context(dummy_reader_context("MERGE_ON_READ"))
+            .with_storage(storage)
+            .with_input_split(dummy_input_split())
+            .with_row_group_selector(std::sync::Arc::new(|_| None))
+            .build()
+            .unwrap();
+        assert!(
+            reader.reader_context.row_group_selector.is_some(),
+            "with_row_group_selector should land on reader_context"
+        );
+        assert!(
+            reader.reader_context.row_filter_builder.is_none(),
+            "the two mechanisms are independent: one may be set without the 
other"
+        );
+    }
+
+    /// Three rows, one per row group. A selector keeping only the first must
+    /// leave the read with that row group's row and no other -- and the volume
+    /// counters must show that the other two were never scanned, which is the
+    /// difference between pruning and filtering.
+    #[tokio::test]
+    async fn a_selector_prunes_row_groups_when_the_read_does_not_merge() {
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let (tmp, base_name, schema) = three_row_groups();
+        let mut reader = test_file_group_reader_for_base_file(tmp.path(), 
&base_name, schema).await;
+        let volume = reader.storage.read_volume();
+        install_selector(&mut reader, |_| Some(vec![0]), false);
+
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(out.num_rows(), 1, "only the kept row group was read");
+        assert_eq!(volume.row_group_selector_calls.load(Relaxed), 1);
+        assert_eq!(volume.row_group_selector_suppressed.load(Relaxed), 0);
+        assert_eq!(volume.file_row_groups.load(Relaxed), 3);
+        assert_eq!(
+            volume.row_groups_read.load(Relaxed),
+            1,
+            "the other two row groups were never fetched"
+        );
+    }
+
+    /// The same selector on a slice that merges, with a predicate that is not
+    /// primary-key-safe. Pruning would drop base rows before the merge could
+    /// update them into a match, so the gate refuses it -- and counts the
+    /// refusal, because a suppressed selector otherwise reads as "no caller 
ever
+    /// installed one": both are zero calls.
+    #[tokio::test]
+    async fn a_selector_the_gate_refuses_is_counted_not_silently_dropped() {
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let (tmp, base_name, schema) = three_row_groups();
+        let mut reader = test_file_group_reader_for_base_file(tmp.path(), 
&base_name, schema).await;
+        let volume = reader.storage.read_volume();
+        reader.input_split = InputSplit::new(
+            Some(base_name.clone()),
+            Some("20240101120000000".to_string()),
+            vec![".f1-0_20240101130000000.log.1_0-1-1".to_string()],
+            String::new(),
+        );
+        install_selector(&mut reader, |_| Some(vec![0]), false);
+
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(out.num_rows(), 3, "every base row still reaches the 
merge");
+        assert_eq!(
+            volume.row_group_selector_calls.load(Relaxed),
+            0,
+            "the selector never ran"
+        );
+        assert_eq!(
+            volume.row_group_selector_suppressed.load(Relaxed),
+            1,
+            "and the reason it never ran is on the record"
+        );
+        assert_eq!(volume.row_groups_read.load(Relaxed), 3);
+    }
+
+    /// The same merging slice with a primary-key-safe predicate: the gate 
opens,
+    /// so the selector runs. Pairs with the case above -- same file, same
+    /// selector, opposite outcome from `mor_pk_safe` alone.
+    #[tokio::test]
+    async fn a_pk_safe_predicate_lets_the_selector_run_on_a_merging_slice() {
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let (tmp, base_name, schema) = three_row_groups();
+        let mut reader = test_file_group_reader_for_base_file(tmp.path(), 
&base_name, schema).await;
+        let volume = reader.storage.read_volume();
+        reader.input_split = InputSplit::new(
+            Some(base_name.clone()),
+            Some("20240101120000000".to_string()),
+            vec![".f1-0_20240101130000000.log.1_0-1-1".to_string()],
+            String::new(),
+        );
+        install_selector(&mut reader, |_| Some(vec![0]), true);
+
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(out.num_rows(), 1);
+        assert_eq!(volume.row_group_selector_calls.load(Relaxed), 1);
+        assert_eq!(volume.row_group_selector_suppressed.load(Relaxed), 0);
+    }
+
+    /// A selector with no opinion reads the whole file -- but the call is 
still
+    /// counted, which is what separates "ran and found nothing" from "never
+    /// installed".
+    #[tokio::test]
+    async fn a_selector_that_declines_reads_every_row_group_and_still_counts() 
{
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let (tmp, base_name, schema) = three_row_groups();
+        let mut reader = test_file_group_reader_for_base_file(tmp.path(), 
&base_name, schema).await;
+        let volume = reader.storage.read_volume();
+        install_selector(&mut reader, |_| None, false);
+
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(out.num_rows(), 3);
+        assert_eq!(volume.row_group_selector_calls.load(Relaxed), 1);
+        assert_eq!(
+            volume.row_groups_read.load(Relaxed),
+            volume.file_row_groups.load(Relaxed),
+            "declining prunes nothing"
+        );
+    }
+
+    /// A three-row base file written one row per row group, so a selector has
+    /// something to choose between.
+    fn three_row_groups() -> (tempfile::TempDir, String, SchemaRef) {
+        use arrow_array::Int32Array;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let schema = 
Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
+            "id",
+            arrow_schema::DataType::Int32,
+            true,
+        )]));
+        let batch = RecordBatch::try_new(
+            schema.clone(),
+            vec![Arc::new(Int32Array::from(vec![7, 8, 9]))],
+        )
+        .unwrap();
+        let base_name = "f1-0_0-1-1_20240101120000000.parquet".to_string();
+        write_parquet_file_in_row_groups(tmp.path(), &base_name, &batch, 1);
+        (tmp, base_name, schema)
+    }
+
+    /// Put a selector and a PK-safety verdict on a reader that was already 
built.
+    fn install_selector(
+        reader: &mut HoodieFileGroupReader,
+        select: fn(&parquet::file::metadata::ParquetMetaData) -> 
Option<Vec<usize>>,
+        mor_pk_safe: bool,
+    ) {
+        let mut context = (*reader.reader_context).clone();
+        context.row_group_selector = Some(std::sync::Arc::new(select));
+        context.mor_pk_safe = mor_pk_safe;
+        reader.reader_context = Arc::new(context);
+    }
+
     // Bootstrap base files are rejected loudly at reader construction.
     // `needs_bootstrap_merge = true` (set when the table has bootstrap base 
files
     // requiring meta/data column reordering) must surface as 
CoreError::Unsupported
@@ -1862,6 +2211,8 @@ mod tests {
         );
     }
 
+    /// A CoW slice never carries log files, so it reaches the gate through the
+    /// "nothing merges" branch rather than through a table-type test.
     #[test]
     fn builder_cow_always_pushes_regardless_of_mor_pk_safe() {
         let storage = 
Storage::new_with_base_url(parse_uri("file:///tmp").unwrap()).unwrap();
@@ -1874,7 +2225,7 @@ mod tests {
             .build()
             .unwrap();
         assert!(
-            reader.reader_context.can_push_row_filter(),
+            reader.base_read_pushdown_is_safe(),
             "CoW path always pushes regardless of mor_pk_safe"
         );
     }
@@ -1903,7 +2254,7 @@ mod tests {
         for use_position in [false, true] {
             for filter in [None, Some(make_row_filter_builder())] {
                 assert_eq!(
-                    base_read_options(filter, None, use_position).batch_size,
+                    base_read_options(filter, None, None, 
use_position).batch_size,
                     Some(MERGE_CHUNK_ROWS),
                     "the base read must ask for the merge's chunk bound rather 
than \
                      inherit one (use_position={use_position})"
diff --git a/crates/core/src/file_group/reader_v2/harness.rs 
b/crates/core/src/file_group/reader_v2/harness.rs
index ad048efb..05ba3bb1 100644
--- a/crates/core/src/file_group/reader_v2/harness.rs
+++ b/crates/core/src/file_group/reader_v2/harness.rs
@@ -133,10 +133,11 @@ pub struct RowFilterSpec {
     /// Marks the filter PK-safe. Mirrors Java's
     /// `SparkFileFormatInternalRowReaderContext.filterIsSafeForPrimaryKey`:
     /// only record-key filters are safe to push under merge, because PKs are
-    /// immutable across upserts. Sets `builder.with_mor_pk_safe`; the
-    /// `can_push_row_filter` gate (`is_cow() || mor_pk_safe`) decides whether
-    /// the filter is actually installed on the base parquet + parquet log
-    /// blocks.
+    /// immutable across upserts. Sets `builder.with_mor_pk_safe`; the reader's
+    /// per-split gate (`base_read_pushdown_is_safe`) decides whether the
+    /// filter is actually installed on the base parquet + parquet log blocks —
+    /// a slice with no log files pushes whatever this says, because nothing
+    /// merges.
     pub mor_pk_safe: bool,
 }
 
@@ -766,21 +767,17 @@ async fn read_case_with_filter(
     let mut reader_context = base_reader_context(case, has_log_files);
     reader_context.schema_handler = schema_handler;
 
-    // The `can_push_row_filter` gate is `is_cow() || mor_pk_safe`, and
-    // `is_cow()` reads `hoodie.table.type` from the table_config. The 
FFI/Spark
-    // path populates this from `hoodie.properties`; the harness's
-    // `ReaderContext::empty()` does not, so set it here to match gold's
-    // pushdown gate. A base-only slice (no log files) is read as COPY_ON_WRITE
-    // (no merge can flip the predicate outcome — the CoW gate branch); a slice
-    // with log files is MERGE_ON_READ (only PK-safe filters may push).
-    let table_type = if has_log_files {
-        "MERGE_ON_READ"
-    } else {
-        "COPY_ON_WRITE"
-    };
+    // `hoodie.table.type` reaches the reader from `hoodie.properties` on the
+    // engine path; the harness's `ReaderContext::empty()` has none, so set it
+    // here. Every fixture these cases read is a MERGE_ON_READ table, and that 
is
+    // what goes in — a base-only slice of one is no longer declared
+    // COPY_ON_WRITE to unlock pushdown. The reader decides that per split now
+    // (`base_read_pushdown_is_safe`): no log files, no merge, so nothing can
+    // flip a predicate's outcome. Faking the table type would hide whether 
that
+    // decision works.
     reader_context.table_config.insert(
         HudiTableConfig::TableType.as_ref().to_string(),
-        table_type.to_string(),
+        "MERGE_ON_READ".to_string(),
     );
 
     let mut reader = HoodieFileGroupReader::builder()
diff --git a/crates/core/src/file_group/reader_v2/harness_tests.rs 
b/crates/core/src/file_group/reader_v2/harness_tests.rs
index f4ddd360..94c4d86f 100644
--- a/crates/core/src/file_group/reader_v2/harness_tests.rs
+++ b/crates/core/src/file_group/reader_v2/harness_tests.rs
@@ -1307,9 +1307,10 @@ fg_case_test!(
 // Java contract 
(SparkFileFormatInternalRowReaderContext.filterIsSafeForPrimaryKey):
 //   - PK-safe filters (record-key columns) may be pushed under MOR merge.
 //   - Data-column filters may be pushed only on the CoW path (no log files).
-//   - The gate `can_push_row_filter() = is_cow() || mor_pk_safe` blocks unsafe
-//     pushes; when blocked, ALL rows return and the post-merge filter (above
-//     the FG reader, e.g. Velox/Spark) evaluates the predicate.
+//   - The gate `base_read_pushdown_is_safe() = no log files on the split ||
+//     mor_pk_safe` blocks unsafe pushes; when blocked, ALL rows return and the
+//     post-merge filter (above the FG reader, e.g. Velox/Spark) evaluates the
+//     predicate.
 //
 // Record-key format for V9Mor8I4UCommitTime: plain id value ("1", "2", ...)
 // (verified by reading the sf base parquet's _hoodie_record_key column).
@@ -1379,17 +1380,18 @@ fg_case_test!(
     }
 );
 
-// (3) Data-column filter on the CoW path (base-only, no logs -> is_cow gate):
-// `age` Gt "27", mor_pk_safe=false. The gate is open via is_cow(), so the
-// filter is pushed: Alice(30) survives, Bob(25) is filtered out.
+// (3) Data-column filter on a base-only slice of a MOR table: `age` Gt "27",
+// mor_pk_safe=false. No log files, so nothing merges and the gate is open on
+// the split alone: the filter is pushed, Alice(30) survives, Bob(25) is
+// filtered out. Poisons if the split-level gate stops opening.
 fg_case_test!(
-    harness_filter_data_col_cow,
+    harness_filter_data_col_base_only,
     FgReaderCase {
-        name: "filter_data_col_cow",
+        name: "filter_data_col_base_only",
         fixture: QuickstartTripsTable::V9Mor8I4UCommitTime,
         partition: "city=sf",
         base_file: I8I4U_SF_BASE,
-        log_files: &[], // base-only => COW path => can_push_row_filter via 
is_cow()
+        log_files: &[], // base-only => nothing merges => the split gate opens
         expect_output_columns: Some(&["id", "name", "age"]),
         row_filter: Some(RowFilterSpec {
             column: "age",
@@ -1405,18 +1407,18 @@ fg_case_test!(
     }
 );
 
-// (4) Logical/typed-column filter on the CoW path: MorLayoutAllDataTypes
+// (4) Logical/typed-column filter on a base-only slice: MorLayoutAllDataTypes
 // base-only read, `long_field` (Int64) Gt "250". Base values long_field =
 // 100,200,300,400,500 for keys k1..k5 (read from the base parquet, pre-merge).
 // => k3,k4,k5 (300,400,500) survive.
 fg_case_test!(
-    harness_filter_logical_type_cow,
+    harness_filter_logical_type_base_only,
     FgReaderCase {
-        name: "filter_logical_type_cow",
+        name: "filter_logical_type_base_only",
         fixture: QuickstartTripsTable::MorLayoutAllDataTypes,
         partition: "",
         base_file: 
"c887c1e8-5fb9-475e-8171-769c5cf10c61-0_0-240-395_20260409030537482.parquet",
-        log_files: &[], // base-only => COW path
+        log_files: &[], // base-only => nothing merges => the split gate opens
         expect_output_columns: Some(&["key", "long_field", "severity"]),
         row_filter: Some(RowFilterSpec {
             column: "long_field",
@@ -1439,8 +1441,7 @@ fg_case_test!(
 // (5) NEGATIVE gate case: data-column filter under MOR merge with
 // mor_pk_safe=false. `age` Gt "27" — pushing this under merge could DROP a
 // base row (Bob, age 25) whose log update might later have made it match, so
-// the gate (can_push_row_filter = is_cow() || mor_pk_safe = false) BLOCKS the
-// push. The post-merge filter (above the FG reader) is responsible instead, so
+// the gate (no log files = false, mor_pk_safe = false) BLOCKS the push. The 
post-merge filter (above the FG reader) is responsible instead, so
 // the FG reader returns ALL merged rows: Alice-V2(31) AND Bob(25).
 // (Unsafe because the predicate evaluated on BASE values can disagree with 
post-merge values for
 // the same key: a log update may change the column so a pruned base row would 
have matched after
@@ -1452,7 +1453,7 @@ fg_case_test!(
         fixture: QuickstartTripsTable::V9Mor8I4UCommitTime,
         partition: "city=sf",
         base_file: I8I4U_SF_BASE,
-        log_files: &[I8I4U_SF_LOG], // MOR merge => is_cow() false
+        log_files: &[I8I4U_SF_LOG], // MOR merge => the split gate stays shut
         expect_output_columns: Some(&["id", "name", "age"]),
         row_filter: Some(RowFilterSpec {
             column: "age",
@@ -1821,9 +1822,9 @@ fg_case_test!(
 // 0-6; IN ("1","4") keeps exactly those two original rows. Unfiltered would
 // be 7 rows.
 fg_case_test!(
-    harness_filter_pk_in_cow,
+    harness_filter_pk_in_base_only,
     FgReaderCase {
-        name: "filter_pk_in_cow",
+        name: "filter_pk_in_base_only",
         fixture: QuickstartTripsTable::V9MorNonpart3Commits,
         partition: "",
         base_file: NONPART_BASE_FILE,
@@ -1850,9 +1851,9 @@ fg_case_test!(
 
 // Boolean Eq: true rows are k2, k4.
 fg_case_test!(
-    harness_filter_boolean_cow,
+    harness_filter_boolean_base_only,
     FgReaderCase {
-        name: "filter_boolean_cow",
+        name: "filter_boolean_base_only",
         fixture: QuickstartTripsTable::MorLayoutAllDataTypes,
         partition: "",
         base_file: 
"c887c1e8-5fb9-475e-8171-769c5cf10c61-0_0-240-395_20260409030537482.parquet",
@@ -1874,9 +1875,9 @@ fg_case_test!(
 
 // Date32 Gt: days > 19754 (2024-02-01) are k3, k4, k5.
 fg_case_test!(
-    harness_filter_date_cow,
+    harness_filter_date_base_only,
     FgReaderCase {
-        name: "filter_date_cow",
+        name: "filter_date_base_only",
         fixture: QuickstartTripsTable::MorLayoutAllDataTypes,
         partition: "",
         base_file: 
"c887c1e8-5fb9-475e-8171-769c5cf10c61-0_0-240-395_20260409030537482.parquet",
@@ -1898,9 +1899,9 @@ fg_case_test!(
 
 // Timestamp(us, UTC) Lt: strictly before k3's 2024-03-01T00:00:03Z are k1, k2.
 fg_case_test!(
-    harness_filter_timestamp_cow,
+    harness_filter_timestamp_base_only,
     FgReaderCase {
-        name: "filter_timestamp_cow",
+        name: "filter_timestamp_base_only",
         fixture: QuickstartTripsTable::MorLayoutAllDataTypes,
         partition: "",
         base_file: 
"c887c1e8-5fb9-475e-8171-769c5cf10c61-0_0-240-395_20260409030537482.parquet",
@@ -1922,9 +1923,9 @@ fg_case_test!(
 
 // Decimal128(20,2) Gt: values > 300.30 are k4 (400.40), k5 (500.50).
 fg_case_test!(
-    harness_filter_decimal_cow,
+    harness_filter_decimal_base_only,
     FgReaderCase {
-        name: "filter_decimal_cow",
+        name: "filter_decimal_base_only",
         fixture: QuickstartTripsTable::MorLayoutAllDataTypes,
         partition: "",
         base_file: 
"c887c1e8-5fb9-475e-8171-769c5cf10c61-0_0-240-395_20260409030537482.parquet",
@@ -1947,9 +1948,9 @@ fg_case_test!(
 // Float32 Gt: 3.4f32 equals k3's stored value exactly (same literal), so the
 // strictly-greater rows are k4 (4.5), k5 (5.6).
 fg_case_test!(
-    harness_filter_float32_cow,
+    harness_filter_float32_base_only,
     FgReaderCase {
-        name: "filter_float32_cow",
+        name: "filter_float32_base_only",
         fixture: QuickstartTripsTable::MorLayoutAllDataTypes,
         partition: "",
         base_file: 
"c887c1e8-5fb9-475e-8171-769c5cf10c61-0_0-240-395_20260409030537482.parquet",
diff --git a/crates/core/src/file_group/reader_v2/log_record_reader.rs 
b/crates/core/src/file_group/reader_v2/log_record_reader.rs
index 3338b111..e398041d 100644
--- a/crates/core/src/file_group/reader_v2/log_record_reader.rs
+++ b/crates/core/src/file_group/reader_v2/log_record_reader.rs
@@ -867,7 +867,20 @@ impl BaseHoodieLogRecordReader {
         // for cannot change what the requested keys merge to, and dropping it 
before
         // the Avro decode is the whole saving.
         .with_key_predicate(self.reader_context.key_predicate.clone())
-        .with_row_filter(if self.reader_context.can_push_row_filter() {
+        // The log gate is `mor_pk_safe`. A log block only exists when the 
slice
+        // HAS log files, so the base read's "does this merge?" disjunct is
+        // false here by construction and its condition reduces to exactly 
this;
+        // naming it directly says what the log path requires.
+        //
+        // One case is not yet excluded: under position-based merge, Java's
+        // `SparkFileFormatInternalRowReaderContext` pushes NO filters into log
+        // files, because the RECORD_POSITIONS bitmap pairs positions to block
+        // records by index and a decode that drops records misaligns the
+        // pairing. Here that combination fails loudly instead (the position
+        // buffer's count-mismatch check): a PK-safe filter that removes
+        // records from a parquet log block errors a position-based read
+        // rather than corrupting it.
+        .with_row_filter(if self.reader_context.mor_pk_safe {
             self.reader_context.row_filter_builder.clone()
         } else {
             None
diff --git a/crates/core/src/file_group/reader_v2/reader_context.rs 
b/crates/core/src/file_group/reader_v2/reader_context.rs
index fe995bbd..883d5ccd 100644
--- a/crates/core/src/file_group/reader_v2/reader_context.rs
+++ b/crates/core/src/file_group/reader_v2/reader_context.rs
@@ -28,7 +28,7 @@
 use super::record_context::RecordContext;
 use super::schema_handler::FileGroupReaderSchemaHandler;
 use crate::config::table::HudiTableConfig;
-use crate::storage::RowFilterBuilder;
+use crate::storage::{RowFilterBuilder, RowGroupSelector};
 use crate::timeline::selector::InstantRange;
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
@@ -130,9 +130,11 @@ pub struct ReaderContext {
     /// parquet files and parquet-format log blocks. Set by the FFI bridge from
     /// the decoded substrait predicate; `None` when no predicate was pushed.
     ///
-    /// Whether it actually gets installed is gated by [`Self::mor_pk_safe`] +
-    /// table type (see callers in `file_group::reader::HoodieFileGroupReader`
-    /// and `file_group::log_file::content::Decoder`).
+    /// Whether it actually gets installed is gated by whether the read merges:
+    /// `HoodieFileGroupReader::base_read_pushdown_is_safe` for the base file
+    /// (no log files on the split, or [`Self::mor_pk_safe`]), and
+    /// [`Self::mor_pk_safe`] alone for a parquet log block, which by 
definition
+    /// only exists on a slice that does merge.
     ///
     /// **Not active for reads through this crate.**
     /// 
[`resolve_reader_context`](crate::file_group::reader_v2::resolver::resolve_reader_context)
@@ -142,6 +144,15 @@ pub struct ReaderContext {
     /// Groundwork for a caller that supplies one; no performance claim about 
this
     /// crate's reads rests on it.
     pub row_filter_builder: Option<RowFilterBuilder>,
+    /// Optional row-group selector for pruning base parquet reads from footer
+    /// statistics; `None` when no caller supplied one.
+    ///
+    /// Gated by the same "does this read merge?" condition as
+    /// [`Self::row_filter_builder`], and for a stronger reason: pruning drops
+    /// base rows before the log merge runs, so on a merging read it could 
remove
+    /// a row the merge would have updated into a match. The row filter at 
least
+    /// sees every row.
+    pub row_group_selector: Option<RowGroupSelector>,
     /// Record keys or key prefixes the read is interested in, pushed into the 
base
     /// file reader so a key-ordered format seeks instead of scanning.
     ///
@@ -214,6 +225,10 @@ impl std::fmt::Debug for ReaderContext {
                 "row_filter_builder",
                 &self.row_filter_builder.as_ref().map(|_| "<closure>"),
             )
+            .field(
+                "row_group_selector",
+                &self.row_group_selector.as_ref().map(|_| "<closure>"),
+            )
             .field("mor_pk_safe", &self.mor_pk_safe)
             .field("completion_gate_inputs", &self.completion_gate_inputs)
             .finish()
@@ -320,82 +335,18 @@ impl ReaderContext {
             table_config: HashMap::new(),
             hoodie_reader_config: HashMap::new(),
             row_filter_builder: None,
+            row_group_selector: None,
             key_predicate: None,
             mor_pk_safe: false,
             completion_gate_inputs: None,
         }
     }
-
-    /// Returns true iff the table is COPY_ON_WRITE per `hoodie.table.type`.
-    /// Defaults to `false` (treat as MOR) on missing/unparseable values so
-    /// callers err on the side of NOT pushing predicates down.
-    pub fn is_cow(&self) -> bool {
-        use crate::config::table::TableTypeValue;
-        use std::str::FromStr;
-        self.table_config
-            .get("hoodie.table.type")
-            .and_then(|v| TableTypeValue::from_str(v).ok())
-            .map(|t| matches!(t, TableTypeValue::CopyOnWrite))
-            .unwrap_or(false)
-    }
-
-    /// Returns true iff this context allows installing the parquet `RowFilter`
-    /// for the current scan. Either the table is CoW (the merge can't flip
-    /// predicate outcomes) or the filter is PK-safe (PKs are immutable across
-    /// upserts, so the predicate's outcome is stable across the base+log
-    /// merge). Mirrors the gate Java applies via the `morFilters`/`allFilters`
-    /// selection in `SparkFileFormatInternalRowReaderContext`.
-    pub fn can_push_row_filter(&self) -> bool {
-        self.is_cow() || self.mor_pk_safe
-    }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
-    fn ctx_with_table_type(t: &str) -> ReaderContext {
-        let mut ctx = ReaderContext::empty();
-        ctx.table_config
-            .insert("hoodie.table.type".to_string(), t.to_string());
-        ctx
-    }
-
-    #[test]
-    fn is_cow_true_for_copy_on_write() {
-        assert!(ctx_with_table_type("COPY_ON_WRITE").is_cow());
-    }
-
-    #[test]
-    fn is_cow_false_for_merge_on_read() {
-        assert!(!ctx_with_table_type("MERGE_ON_READ").is_cow());
-    }
-
-    #[test]
-    fn is_cow_false_when_table_type_missing() {
-        // Defaults to MOR (conservative) so non-PK predicates don't sneak in.
-        assert!(!ReaderContext::empty().is_cow());
-    }
-
-    #[test]
-    fn can_push_row_filter_cow_unconditionally() {
-        // CoW: pushdown is always safe regardless of mor_pk_safe.
-        let mut ctx = ctx_with_table_type("COPY_ON_WRITE");
-        ctx.mor_pk_safe = false;
-        assert!(ctx.can_push_row_filter());
-        ctx.mor_pk_safe = true;
-        assert!(ctx.can_push_row_filter());
-    }
-
-    #[test]
-    fn can_push_row_filter_mor_only_when_pk_safe() {
-        let mut ctx = ctx_with_table_type("MERGE_ON_READ");
-        ctx.mor_pk_safe = false;
-        assert!(!ctx.can_push_row_filter());
-        ctx.mor_pk_safe = true;
-        assert!(ctx.can_push_row_filter());
-    }
-
     #[test]
     fn record_key_fields_from_default_meta_field_mode() {
         // Default (populate.meta.fields absent → true): record_key_fields()
diff --git a/crates/core/src/file_group/reader_v2/resolver.rs 
b/crates/core/src/file_group/reader_v2/resolver.rs
index 3930869a..89076f3a 100644
--- a/crates/core/src/file_group/reader_v2/resolver.rs
+++ b/crates/core/src/file_group/reader_v2/resolver.rs
@@ -97,8 +97,10 @@ pub(crate) fn resolve_reader_context(
         needs_bootstrap_merge: false,
         enable_logical_timestamp_field_repair: false,
         // Predicate pushdown into the merge path has no caller here, so no
-        // filter is installed and the primary-key-safety gate is irrelevant.
+        // filter is installed, nothing prunes row groups, and the
+        // primary-key-safety gate is irrelevant.
         row_filter_builder: None,
+        row_group_selector: None,
         key_predicate: None,
         mor_pk_safe: false,
         // The table-version < 8 completion gate needs a timeline the caller
diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs
index 2f7c80ff..69e7474a 100644
--- a/crates/core/src/storage/mod.rs
+++ b/crates/core/src/storage/mod.rs
@@ -21,6 +21,7 @@
 use std::collections::HashMap;
 use std::path::PathBuf;
 use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
 
 use async_recursion::async_recursion;
 use bytes::Bytes;
@@ -59,12 +60,134 @@ pub type RowFilterBuilder = Arc<
         + Sync,
 >;
 
+/// Chooses which row groups a read fetches, from the file's parsed footer.
+/// Returning `None` means "no opinion, read them all".
+///
+/// This is the only mechanism on the read path that can avoid reading bytes: a
+/// [`RowFilterBuilder`] decides per row after the predicate columns are 
decoded,
+/// so it saves decode, never IO, while a row group excluded here is never
+/// fetched.
+///
+/// The selector must be CONSERVATIVE. Keeping a row group that cannot match
+/// costs only time; dropping one that can match silently loses rows, and 
nothing
+/// downstream can restore them.
+///
+/// `Arc` for the same reason as [`RowFilterBuilder`]: options holding it stay
+/// `Clone`, and it may run on any worker thread.
+pub type RowGroupSelector =
+    Arc<dyn Fn(&parquet::file::metadata::ParquetMetaData) -> 
Option<Vec<usize>> + Send + Sync>;
+
 #[derive(Clone, Debug)]
 pub struct Storage {
     pub(crate) base_url: Arc<Url>,
     pub(crate) object_store: Arc<dyn ObjectStore>,
     pub(crate) options: Arc<HashMap<String, String>>,
     pub(crate) hudi_configs: Arc<HudiConfigs>,
+    /// Read-volume counters for the base-file reads made through this
+    /// `Storage`.
+    ///
+    /// Here rather than on a read parameter so no read signature changes. The
+    /// scope is this `Storage`'s lifetime, which is as narrow as the caller
+    /// makes it: a per-read `Storage` yields per-read counters, while a
+    /// `FileGroupReader` builds one `Storage` in its constructor and reuses
+    /// it, so every slice read through that reader accumulates into the same
+    /// counters. The `object_store` inside could not carry them: it is shared
+    /// even wider, so its counts would be everyone's.
+    pub(crate) read_volume: Arc<ReadVolume>,
+}
+
+/// Read-volume counters for one [`Storage`]'s lifetime.
+///
+/// Whether a predicate was pushed is not the same question as what a push
+/// bought: a parquet `RowFilter` can be installed on every file and still read
+/// every byte, because it decides per row after the predicate columns are
+/// decoded. Only pruning avoids IO. These counters separate the two.
+///
+/// `bytes_read` and `io_calls` are counted at the `AsyncFileReader` boundary,
+/// which makes them exact and independent of the OS page cache: a warm re-read
+/// reports the same bytes as a cold one. Wall-clock does not have that 
property,
+/// which is what makes these the transferable numbers when comparing read 
paths.
+/// The boundary also bounds the scope: footer fetches happen before the
+/// counting wrapper is installed and log-file IO never crosses it, so these
+/// two count base-file column-chunk reads only.
+///
+/// All fields are `AtomicU64` under an `Arc` because the parquet stream is
+/// polled on whichever worker thread drives it, while a consumer may read the
+/// counters from another. `Relaxed` throughout: these are advisory counters, 
and
+/// the happens-before that makes them visible is the consumer draining the
+/// stream.
+#[derive(Debug, Default)]
+pub struct ReadVolume {
+    /// Bytes actually fetched from the object store, summed over every range 
read.
+    pub bytes_read: AtomicU64,
+    /// Number of `get_bytes` / `get_byte_ranges` calls — round trips, not 
ranges.
+    /// A two-pass read (predicate columns, then the selected rows) shows up 
here
+    /// as roughly double the calls of a single-pass read over the same file.
+    pub io_calls: AtomicU64,
+    /// Row groups the reader was configured to scan. Equal to 
`file_row_groups`
+    /// until something prunes; the gap between the two is what pruning bought.
+    pub row_groups_read: AtomicU64,
+    /// Row groups the file contains. Denominator for the line above.
+    pub file_row_groups: AtomicU64,
+    /// Times the row-group selector closure actually RAN.
+    ///
+    /// Separate from its outcome on purpose. A selector returns `None` when it
+    /// cannot prune anything, so `row_groups_read == file_row_groups` reads 
the
+    /// same whether the selector ran and found nothing or was never installed.
+    /// Only this counter separates them.
+    pub row_group_selector_calls: AtomicU64,
+    /// Times a selector WAS installed by the caller but the merge-safety gate
+    /// refused to pass it down.
+    ///
+    /// Without this the gate silently defeats the counter above: a suppressed
+    /// selector is a third state that also reads zero calls. Read the two
+    /// together:
+    ///   calls > 0                   the selector ran
+    ///   calls == 0, suppressed > 0  the gate refused it (the read merges, and
+    ///                               the predicate is not primary-key-safe)
+    ///   calls == 0, suppressed == 0 no caller ever installed one
+    pub row_group_selector_suppressed: AtomicU64,
+    /// Rows the file contains, from parquet metadata.
+    pub file_rows: AtomicU64,
+    /// Rows the stream actually yielded, after any row filter. `file_rows -
+    /// rows_out` is what filtering removed; `bytes_read` says what it cost to
+    /// remove it.
+    pub rows_out: AtomicU64,
+}
+
+impl ReadVolume {
+    /// One completed fetch: its bytes, and the round trip that carried them.
+    pub(crate) fn add_bytes(&self, n: u64) {
+        self.bytes_read.fetch_add(n, Ordering::Relaxed);
+        self.io_calls.fetch_add(1, Ordering::Relaxed);
+    }
+
+    /// What the file holds, read off the footer the reader has already 
fetched.
+    pub(crate) fn record_file_shape(&self, row_groups: u64, rows: u64) {
+        self.file_row_groups
+            .fetch_add(row_groups, Ordering::Relaxed);
+        self.file_rows.fetch_add(rows, Ordering::Relaxed);
+    }
+
+    pub(crate) fn add_row_groups_read(&self, n: u64) {
+        self.row_groups_read.fetch_add(n, Ordering::Relaxed);
+    }
+
+    /// The selector ran. Counted whether or not it managed to prune.
+    pub(crate) fn record_selector_call(&self) {
+        self.row_group_selector_calls
+            .fetch_add(1, Ordering::Relaxed);
+    }
+
+    /// A caller installed a selector and the safety gate declined to pass it 
on.
+    pub(crate) fn record_selector_suppressed(&self) {
+        self.row_group_selector_suppressed
+            .fetch_add(1, Ordering::Relaxed);
+    }
+
+    pub(crate) fn add_rows_out(&self, n: u64) {
+        self.rows_out.fetch_add(n, Ordering::Relaxed);
+    }
 }
 
 impl Storage {
@@ -93,11 +216,18 @@ impl Storage {
                 object_store: Arc::new(object_store),
                 options,
                 hudi_configs,
+                read_volume: Arc::new(ReadVolume::default()),
             })),
             Err(e) => Err(Creation(format!("Failed to create storage: {e}"))),
         }
     }
 
+    /// Clone of this `Storage`'s read-volume counters, for a consumer that
+    /// outlives the read and reports them once the stream has drained.
+    pub fn read_volume(&self) -> Arc<ReadVolume> {
+        self.read_volume.clone()
+    }
+
     /// Build storage over a caller-supplied object store.
     ///
     /// Test-only, so a test can wrap the real store and observe the requests a
@@ -116,6 +246,7 @@ impl Storage {
             object_store,
             options: Arc::new(HashMap::new()),
             hudi_configs,
+            read_volume: Arc::new(ReadVolume::default()),
         })
     }
 

Reply via email to