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 8207dfd1 perf(core): push read filters into the parquet reader (#911)
8207dfd1 is described below

commit 8207dfd19ec570cd7596c66dcdc09def9759c809
Author: Y Ethan Guo <[email protected]>
AuthorDate: Thu Oct 29 23:53:30 2026 -0700

    perf(core): push read filters into the parquet reader (#911)
    
    `file_group::pushdown::filter_pushdown` turns `ReadOptions.filters` into a 
parquet `RowFilter` (the filter columns are decoded first, the rest only for 
the rows that pass) and a row-group selector over the footer statistics, and 
`FileGroupReader` hands them to the merge-on-read reader for every slice it 
reads: every filter for a slice without log files, and only the record key 
filters for a slice whose log files merge. The reader's existing gate then 
pushes nothing under a position-ba [...]
---
 crates/core/src/file_group/mod.rs                  |   1 +
 crates/core/src/file_group/pushdown.rs             | 337 +++++++++++++++++++++
 crates/core/src/file_group/reader.rs               | 183 ++++++++++-
 crates/core/src/file_group/reader_v2/adapter.rs    |  19 ++
 .../src/file_group/reader_v2/reader_context.rs     |  13 +-
 .../core/tests/table_read_filter_pushdown_tests.rs | 211 +++++++++++++
 6 files changed, 752 insertions(+), 12 deletions(-)

diff --git a/crates/core/src/file_group/mod.rs 
b/crates/core/src/file_group/mod.rs
index 15b7cbf1..b2ca4402 100644
--- a/crates/core/src/file_group/mod.rs
+++ b/crates/core/src/file_group/mod.rs
@@ -25,6 +25,7 @@ pub mod base_file;
 pub mod builder;
 pub mod file_slice;
 pub mod log_file;
+pub(crate) mod pushdown;
 pub mod reader;
 pub(crate) mod reader_v2;
 pub mod record_batches;
diff --git a/crates/core/src/file_group/pushdown.rs 
b/crates/core/src/file_group/pushdown.rs
new file mode 100644
index 00000000..f46dcf6f
--- /dev/null
+++ b/crates/core/src/file_group/pushdown.rs
@@ -0,0 +1,337 @@
+// 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.
+
+//! What a slice read pushes into the parquet reader from the caller's filters.
+
+use std::sync::Arc;
+
+use arrow_array::RecordBatch;
+use arrow_schema::{ArrowError, DataType, Schema};
+use parquet::arrow::arrow_reader::{ArrowPredicateFn, RowFilter};
+use parquet::arrow::{ProjectionMask, parquet_to_arrow_schema};
+use parquet::file::metadata::ParquetMetaData;
+
+use crate::expr::filter::{Filter, filters_to_row_mask};
+use crate::metadata::meta_field::MetaField;
+use crate::statistics::StatisticsContainer;
+use crate::storage::{RowFilterBuilder, RowGroupSelector};
+use crate::table::file_pruner::FilePruner;
+
+/// The caller's filters compiled for the parquet reader: a row-group selector
+/// over the footer statistics and a row filter that decodes the filter
+/// columns first and the rest only for the rows that pass.
+///
+/// Both are conservative. A file without one of the filter columns gets no
+/// row filter, and a row group without statistics is kept; the row-level mask
+/// the reader applies afterwards still decides every row.
+pub(crate) struct FilterPushdown {
+    pub row_filter: RowFilterBuilder,
+    pub row_groups: RowGroupSelector,
+    /// Every filter is on a record key field, which no log record can change,
+    /// so the filter may drop base rows before a merge-on-read merge. Always
+    /// true for a slice that merges, which keeps only those filters.
+    pub mor_pk_safe: bool,
+}
+
+/// `table_schema` is the schema the read returns rows in. A file written
+/// before a type widening stores a filter column in a narrower type, and a
+/// literal compared in that type can disagree with the same comparison in
+/// the table's type, so such a file gets nothing pushed.
+///
+/// A slice that `merges` log records into its base file pushes only the
+/// record key filters: a filter on any other column could drop a base row
+/// that a log record would have lost to.
+pub(crate) fn filter_pushdown(
+    filters: &[Filter],
+    record_key_fields: &[String],
+    table_schema: &Schema,
+    merges: bool,
+) -> Option<FilterPushdown> {
+    let is_key = |filter: &&Filter| {
+        filter.field == MetaField::RecordKey.as_ref() || 
record_key_fields.contains(&filter.field)
+    };
+    let filters: Vec<Filter> = if merges {
+        filters.iter().filter(is_key).cloned().collect()
+    } else {
+        filters.to_vec()
+    };
+    if filters.is_empty() {
+        return None;
+    }
+    let mor_pk_safe = filters.iter().all(|filter| is_key(&filter));
+    let table_types: Vec<(String, DataType)> = filters
+        .iter()
+        .map(|filter| {
+            let data_type = table_schema
+                .field_with_name(&filter.field)
+                .map(|f| f.data_type().clone())
+                .unwrap_or(DataType::Null);
+            (filter.field.clone(), data_type)
+        })
+        .collect();
+    // Every filter column, stored in the file as the table has it.
+    let same_types = |file_schema: &Schema, table_types: &[(String, 
DataType)]| {
+        table_types.iter().all(|(name, data_type)| {
+            file_schema
+                .field_with_name(name)
+                .is_ok_and(|f| f.data_type() == data_type)
+        })
+    };
+    let for_rows = filters.clone();
+    let row_types = table_types.clone();
+    // Checked against the schema the reader decodes with (its embedded Arrow
+    // hint included), which is what the predicate sees.
+    let row_filter: RowFilterBuilder = Arc::new(move |parquet_schema, 
read_schema| {
+        if !same_types(read_schema, &row_types) {
+            return None;
+        }
+        let root = parquet_schema.root_schema();
+        let mut columns: Vec<usize> = for_rows
+            .iter()
+            .map(|filter| {
+                root.get_fields()
+                    .iter()
+                    .position(|field| field.name() == filter.field)
+            })
+            .collect::<Option<_>>()?;
+        columns.sort_unstable();
+        columns.dedup();
+        let mask = ProjectionMask::roots(parquet_schema, columns);
+        let filters = for_rows.clone();
+        let predicate = ArrowPredicateFn::new(mask, move |batch: RecordBatch| {
+            filters_to_row_mask(&filters, &batch)
+                .map_err(|e| ArrowError::ComputeError(e.to_string()))
+        });
+        Some(RowFilter::new(vec![Box::new(predicate)]))
+    });
+    // Statistics leave NaN out of a float column's range, so a range check on
+    // one could drop a row group holding NaN; those columns are left to the
+    // row filter.
+    let for_groups: Vec<Filter> = filters
+        .iter()
+        .filter(|filter| {
+            !matches!(
+                table_schema
+                    .field_with_name(&filter.field)
+                    .map(|f| f.data_type()),
+                Ok(DataType::Float16 | DataType::Float32 | DataType::Float64)
+            )
+        })
+        .cloned()
+        .collect();
+    let group_types = table_types;
+    let row_groups: RowGroupSelector = Arc::new(move |metadata: 
&ParquetMetaData| {
+        let file = metadata.file_metadata();
+        let schema =
+            parquet_to_arrow_schema(file.schema_descr(), 
file.key_value_metadata()).ok()?;
+        if !same_types(&schema, &group_types) {
+            return None;
+        }
+        let pruner = FilePruner::new(&for_groups, &schema, 
&Schema::empty()).ok()?;
+        Some(
+            (0..metadata.num_row_groups())
+                .filter(|&index| {
+                    pruner.should_include(&StatisticsContainer::from_row_group(
+                        metadata.row_group(index),
+                        &schema,
+                    ))
+                })
+                .collect(),
+        )
+    });
+    Some(FilterPushdown {
+        row_filter,
+        row_groups,
+        mor_pk_safe,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::expr::filter::col;
+    use arrow_array::{Int64Array, StringArray};
+    use arrow_schema::{DataType, Field};
+    use bytes::Bytes;
+    use parquet::arrow::ArrowWriter;
+    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
+    use parquet::file::properties::WriterProperties;
+
+    fn schema() -> Schema {
+        Schema::new(vec![
+            Field::new("id", DataType::Utf8, false),
+            Field::new("v", DataType::Int64, false),
+        ])
+    }
+
+    /// Ten rows `k00..k09` with values `0..9`, two rows per row group.
+    fn parquet_file() -> Bytes {
+        let schema = Arc::new(schema());
+        let batch = RecordBatch::try_new(
+            schema.clone(),
+            vec![
+                Arc::new(StringArray::from(
+                    (0..10).map(|i| format!("k{i:02}")).collect::<Vec<_>>(),
+                )),
+                Arc::new(Int64Array::from_iter_values(0..10)),
+            ],
+        )
+        .unwrap();
+        let props = WriterProperties::builder()
+            .set_max_row_group_row_count(Some(2))
+            .build();
+        let mut out = Vec::new();
+        let mut writer = ArrowWriter::try_new(&mut out, schema, 
Some(props)).unwrap();
+        writer.write(&batch).unwrap();
+        writer.close().unwrap();
+        Bytes::from(out)
+    }
+
+    fn ids_through(pushdown: &FilterPushdown, file: Bytes) -> (Vec<usize>, 
Vec<String>) {
+        let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
+        let groups = (pushdown.row_groups)(builder.metadata()).unwrap();
+        let filter = (pushdown.row_filter)(builder.parquet_schema(), 
builder.schema()).unwrap();
+        let reader = builder
+            .with_row_groups(groups.clone())
+            .with_row_filter(filter)
+            .build()
+            .unwrap();
+        let ids = reader
+            .map(|b| b.unwrap())
+            .flat_map(|b| {
+                b.column(0)
+                    .as_any()
+                    .downcast_ref::<StringArray>()
+                    .unwrap()
+                    .iter()
+                    .map(|v| v.unwrap().to_string())
+                    .collect::<Vec<_>>()
+            })
+            .collect();
+        (groups, ids)
+    }
+
+    #[test]
+    fn test_pushdown_selects_row_groups_and_rows() {
+        let file = parquet_file();
+        let one = filter_pushdown(
+            &[col("id").eq("k05")],
+            &["id".to_string()],
+            &schema(),
+            false,
+        )
+        .unwrap();
+        assert!(one.mor_pk_safe);
+        assert_eq!(
+            ids_through(&one, file.clone()),
+            (vec![2], vec!["k05".to_string()])
+        );
+
+        let two =
+            filter_pushdown(&[col("id").in_list(["k01", "k08"])], &[], 
&schema(), false).unwrap();
+        assert!(!two.mor_pk_safe);
+        assert_eq!(
+            ids_through(&two, file.clone()),
+            (vec![0, 4], vec!["k01".to_string(), "k08".to_string()])
+        );
+
+        let both = filter_pushdown(
+            &[col("v").gte("6"), col("id").ne("k07")],
+            &[],
+            &schema(),
+            false,
+        )
+        .unwrap();
+        assert_eq!(
+            ids_through(&both, file.clone()),
+            (
+                vec![3, 4],
+                vec!["k06".to_string(), "k08".to_string(), "k09".to_string()]
+            )
+        );
+
+        let none = filter_pushdown(&[col("v").gt("100")], &[], &schema(), 
false).unwrap();
+        assert_eq!(ids_through(&none, file), (vec![], vec![]));
+    }
+
+    /// A slice that merges keeps only the record key half of a mixed filter.
+    #[test]
+    fn test_a_merging_slice_pushes_only_record_key_filters() {
+        let file = parquet_file();
+        let keys = ["id".to_string()];
+        let mixed = [col("id").in_list(["k01", "k08"]), col("v").eq("8")];
+
+        let merging = filter_pushdown(&mixed, &keys, &schema(), true).unwrap();
+        assert!(merging.mor_pk_safe);
+        assert_eq!(
+            ids_through(&merging, file.clone()),
+            (vec![0, 4], vec!["k01".to_string(), "k08".to_string()])
+        );
+        let plain = filter_pushdown(&mixed, &keys, &schema(), false).unwrap();
+        assert!(!plain.mor_pk_safe);
+        assert_eq!(
+            ids_through(&plain, file),
+            (vec![4], vec!["k08".to_string()])
+        );
+        assert!(filter_pushdown(&[col("v").eq("8")], &keys, &schema(), 
true).is_none());
+    }
+
+    /// A file holding a filter column in another type than the table (a file
+    /// from before a widening) gets nothing pushed, and a float column gets no
+    /// row-group pruning.
+    #[test]
+    fn test_pushdown_skips_a_narrower_file_type_and_float_statistics() {
+        let file = parquet_file();
+        let wide = Schema::new(vec![
+            Field::new("id", DataType::Utf8, false),
+            Field::new("v", DataType::Decimal128(20, 0), false),
+        ]);
+        let pushdown = filter_pushdown(&[col("v").gt("5")], &[], &wide, 
false).unwrap();
+        let builder = 
ParquetRecordBatchReaderBuilder::try_new(file.clone()).unwrap();
+        assert!((pushdown.row_filter)(builder.parquet_schema(), 
builder.schema()).is_none());
+        assert!((pushdown.row_groups)(builder.metadata()).is_none());
+
+        let floats = Schema::new(vec![
+            Field::new("id", DataType::Utf8, false),
+            Field::new("v", DataType::Float64, false),
+        ]);
+        let pushdown = filter_pushdown(
+            &[col("v").gt("5"), col("id").eq("k01")],
+            &[],
+            &floats,
+            false,
+        )
+        .unwrap();
+        let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
+        // The file stores `v` as Int64, so the type check skips the file; with
+        // a Float64 file the selector would still ignore the `v` filter.
+        assert!((pushdown.row_groups)(builder.metadata()).is_none());
+    }
+
+    /// A column the file does not have gets no row filter and no row-group
+    /// pruning; the reader's mask still handles it.
+    #[test]
+    fn test_pushdown_skips_a_column_the_file_lacks() {
+        let file = parquet_file();
+        let pushdown = filter_pushdown(&[col("missing").eq("x")], &[], 
&schema(), false).unwrap();
+        let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
+        assert!((pushdown.row_filter)(builder.parquet_schema(), 
builder.schema()).is_none());
+        // No opinion on row groups either: the reader fetches them all.
+        assert!((pushdown.row_groups)(builder.metadata()).is_none());
+        assert!(filter_pushdown(&[], &[], &schema(), false).is_none());
+    }
+}
diff --git a/crates/core/src/file_group/reader.rs 
b/crates/core/src/file_group/reader.rs
index 4381d4b9..e2996e28 100644
--- a/crates/core/src/file_group/reader.rs
+++ b/crates/core/src/file_group/reader.rs
@@ -354,15 +354,42 @@ impl FileGroupReader {
         if unchanged { None } else { Some(target) }
     }
 
+    /// The caller's filters, compiled for the parquet reader: all of them for
+    /// a slice read without log files, only the record key filters for one
+    /// that `merges` them. The row-level mask applied afterwards still decides
+    /// every row.
+    fn filter_pushdown(
+        &self,
+        options: &ReadOptions,
+        data_schema: &arrow_schema::Schema,
+        merges: bool,
+    ) -> Option<crate::file_group::pushdown::FilterPushdown> {
+        let record_key_fields: Vec<String> = self
+            .hudi_configs
+            .try_get(HudiTableConfig::RecordKeyFields)
+            .ok()
+            .flatten()
+            .map(Into::into)
+            .unwrap_or_default();
+        crate::file_group::pushdown::filter_pushdown(
+            &options.filters,
+            &record_key_fields,
+            data_schema,
+            merges,
+        )
+    }
+
     /// Read one slice through the merge-on-read reader.
     ///
-    /// Filters and projection are not applied here: the caller runs the result
-    /// through the same `apply_eager_options` the other paths use, so the two
-    /// engines cannot disagree about what a filter means.
+    /// The caller's filters reach the parquet reader as a pushdown (see
+    /// [`Self::filter_pushdown`]); they are still applied, with the
+    /// projection, by the caller's `apply_eager_options`, so the two engines
+    /// cannot disagree about what a filter means.
     async fn read_via_v2(
         &self,
         base_file_path: &str,
         log_file_paths: Vec<String>,
+        options: &ReadOptions,
     ) -> Result<RecordBatch> {
         let data_schema = self.resolved_data_schema(base_file_path).await?;
         if let Some(reason) = 
crate::file_group::reader_v2::adapter::refuse_reason(
@@ -382,12 +409,14 @@ impl FileGroupReader {
         // Per file slice, so debug: a wide read would repeat this once per
         // slice, and which reader served the table is not news N times over.
         log::debug!("reading '{base_file_path}' with file group reader version 
2");
+        let pushdown = self.filter_pushdown(options, &data_schema, 
!log_file_paths.is_empty());
         let merged = crate::file_group::reader_v2::adapter::read_file_slice(
             self.hudi_configs.clone(),
             self.storage.clone(),
             base_file_path,
             log_file_paths,
             partition_path,
+            pushdown,
             Some(data_schema),
             self.completion_gate_inputs.clone(),
         )
@@ -620,7 +649,8 @@ impl FileGroupReader {
             } else {
                 log_file_paths
             };
-            self.read_via_v2(base_file_path, log_file_paths).await?
+            self.read_via_v2(base_file_path, log_file_paths, &options)
+                .await?
         } else if base_file_only {
             // Nothing to merge — a copy-on-write slice, or a read-optimized 
read
             // that ignores the log files. Served by the base file reader, not 
by
@@ -833,12 +863,14 @@ impl FileGroupReader {
 
         // Per file slice — see the eager sibling.
         log::debug!("streaming '{base_file_path}' with file group reader 
version 2");
+        let pushdown = self.filter_pushdown(options, &data_schema, 
!log_file_paths.is_empty());
         let merged = 
crate::file_group::reader_v2::adapter::read_file_slice_stream(
             self.hudi_configs.clone(),
             self.storage.clone(),
             base_file_path,
             log_file_paths,
             partition_path,
+            pushdown,
             Some(data_schema),
             self.completion_gate_inputs.clone(),
         )
@@ -1222,6 +1254,149 @@ mod tests {
     use std::sync::Arc;
     use url::Url;
 
+    /// A filtered slice read reaches the parquet reader: with two rows per
+    /// row group, a key filter fetches one row group of the file.
+    #[tokio::test]
+    async fn test_filters_prune_row_groups_of_a_slice_read() {
+        use crate::table::Table;
+        use arrow_array::{Int64Array, StringArray};
+        use arrow_schema::{DataType, Field, Schema};
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let dir = tempfile::tempdir().unwrap();
+        let mut table = Table::create(dir.path().to_str().unwrap())
+            .with_table_name("t")
+            .with_record_key_fields(["id"])
+            .with_option("hoodie.parquet.block.size", "2048")
+            .with_option("hoodie.copyonwrite.record.size.estimate", "1024")
+            .create()
+            .await
+            .unwrap();
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("id", DataType::Utf8, false),
+            Field::new("value", DataType::Int64, false),
+        ]));
+        let batch = RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(StringArray::from(
+                    (0..10).map(|i| format!("k{i:02}")).collect::<Vec<_>>(),
+                )),
+                Arc::new(Int64Array::from_iter_values(0..10)),
+            ],
+        )
+        .unwrap();
+        table.append([batch]).await.unwrap();
+        let base_file = std::fs::read_dir(dir.path())
+            .unwrap()
+            .filter_map(|e| e.ok())
+            .map(|e| e.file_name().to_string_lossy().to_string())
+            .find(|name| name.ends_with(".parquet"))
+            .unwrap();
+
+        let table = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+        let reader = table
+            .create_file_group_reader_with_options(
+                Some(&ReadOptions::new()),
+                std::iter::empty::<(&str, &str)>(),
+            )
+            .await
+            .unwrap();
+        let volume = reader.storage.read_volume();
+        let options = ReadOptions::new()
+            .with_filters([("id", "=", "k05")])
+            .unwrap();
+        let batch = reader
+            .read_file_slice_from_paths(&base_file, Vec::<String>::new(), 
&options)
+            .await
+            .unwrap();
+        assert_eq!(batch.num_rows(), 1);
+        assert_eq!(volume.file_row_groups.load(Relaxed), 5);
+        assert_eq!(volume.row_groups_read.load(Relaxed), 1);
+    }
+
+    /// On a slice whose log files merge, the record key half of a mixed
+    /// filter still reaches the parquet reader, and the merged update wins.
+    #[tokio::test]
+    async fn test_key_filters_prune_row_groups_of_a_merging_slice_read() {
+        use crate::config::table::TableTypeValue;
+        use crate::table::Table;
+        use arrow_array::{Int64Array, StringArray};
+        use arrow_schema::{DataType, Field, Schema};
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let dir = tempfile::tempdir().unwrap();
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("id", DataType::Utf8, false),
+            Field::new("value", DataType::Int64, false),
+        ]));
+        let batch = |ids: Vec<String>, values: Vec<i64>| {
+            RecordBatch::try_new(
+                schema.clone(),
+                vec![
+                    Arc::new(StringArray::from(ids)),
+                    Arc::new(Int64Array::from(values)),
+                ],
+            )
+            .unwrap()
+        };
+        let mut table = Table::create(dir.path().to_str().unwrap())
+            .with_table_name("t")
+            .with_record_key_fields(["id"])
+            .with_table_type(TableTypeValue::MergeOnRead)
+            .with_option("hoodie.parquet.block.size", "2048")
+            .with_option("hoodie.copyonwrite.record.size.estimate", "1024")
+            .create()
+            .await
+            .unwrap();
+        table
+            .append([batch(
+                (0..10).map(|i| format!("k{i:02}")).collect(),
+                (0..10).collect(),
+            )])
+            .await
+            .unwrap();
+        table
+            .upsert([batch(vec!["k05".to_string()], vec![500])])
+            .await
+            .unwrap();
+        let names: Vec<String> = std::fs::read_dir(dir.path())
+            .unwrap()
+            .filter_map(|e| e.ok())
+            .map(|e| e.file_name().to_string_lossy().to_string())
+            .collect();
+        let base_file = names.iter().find(|n| 
n.ends_with(".parquet")).unwrap();
+        let log_files: Vec<&String> = names.iter().filter(|n| 
n.contains(".log.")).collect();
+        assert_eq!(log_files.len(), 1, "the upsert went to a log");
+
+        let table = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+        let reader = table
+            .create_file_group_reader_with_options(
+                Some(&ReadOptions::new()),
+                std::iter::empty::<(&str, &str)>(),
+            )
+            .await
+            .unwrap();
+        let volume = reader.storage.read_volume();
+        let options = ReadOptions::new()
+            .with_filters([("id", "=", "k05"), ("value", "=", "500")])
+            .unwrap();
+        let batch = reader
+            .read_file_slice_from_paths(base_file, log_files, &options)
+            .await
+            .unwrap();
+        assert_eq!(batch.num_rows(), 1);
+        let values = batch
+            .column_by_name("value")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(values.value(0), 500);
+        assert_eq!(volume.file_row_groups.load(Relaxed), 5);
+        assert_eq!(volume.row_groups_read.load(Relaxed), 1);
+    }
+
     const TEST_SAMPLE_BASE_FILE: &str =
         
"a079bdb3-731c-4894-b855-abfcd6921007-0_0-203-274_20240418173551906.parquet";
 
diff --git a/crates/core/src/file_group/reader_v2/adapter.rs 
b/crates/core/src/file_group/reader_v2/adapter.rs
index 24c4d30e..c071524b 100644
--- a/crates/core/src/file_group/reader_v2/adapter.rs
+++ b/crates/core/src/file_group/reader_v2/adapter.rs
@@ -33,6 +33,7 @@ use crate::Result;
 use crate::config::HudiConfigs;
 use crate::config::read::HudiReadConfig;
 use crate::error::CoreError;
+use crate::file_group::pushdown::FilterPushdown;
 use crate::file_group::reader_v2::MAX_INSTANT_TIME;
 use crate::file_group::reader_v2::engine::HoodieFileGroupReader;
 use crate::file_group::reader_v2::input_split::InputSplit;
@@ -50,12 +51,14 @@ use std::sync::Arc;
 /// to resolve partition-valued fields. `data_schema` is the table's schema; 
the
 /// merge-on-read reader requires it up front rather than deriving it from the
 /// data as the existing reader does.
+#[allow(clippy::too_many_arguments)]
 pub(crate) async fn read_file_slice(
     hudi_configs: Arc<HudiConfigs>,
     storage: Arc<Storage>,
     base_file_path: &str,
     log_file_paths: Vec<String>,
     partition_path: String,
+    pushdown: Option<FilterPushdown>,
     data_schema: Option<SchemaRef>,
     completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
 ) -> Result<RecordBatch> {
@@ -65,6 +68,7 @@ pub(crate) async fn read_file_slice(
         base_file_path,
         log_file_paths,
         partition_path,
+        pushdown,
         data_schema,
         completion_gate_inputs,
     )?;
@@ -77,12 +81,14 @@ pub(crate) async fn read_file_slice(
 /// Same merge as [`read_file_slice`], but the base file is decoded one row 
group
 /// at a time instead of whole, so peak memory tracks a row group rather than 
the
 /// file. Arguments carry the same meaning.
+#[allow(clippy::too_many_arguments)]
 pub(crate) async fn read_file_slice_stream(
     hudi_configs: Arc<HudiConfigs>,
     storage: Arc<Storage>,
     base_file_path: &str,
     log_file_paths: Vec<String>,
     partition_path: String,
+    pushdown: Option<FilterPushdown>,
     data_schema: Option<SchemaRef>,
     completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
 ) -> Result<futures::stream::BoxStream<'static, Result<RecordBatch>>> {
@@ -92,6 +98,7 @@ pub(crate) async fn read_file_slice_stream(
         base_file_path,
         log_file_paths,
         partition_path,
+        pushdown,
         data_schema,
         completion_gate_inputs,
     )?;
@@ -101,12 +108,14 @@ pub(crate) async fn read_file_slice_stream(
 
 /// Assemble the reader both entry points use, so the eager and streaming reads
 /// cannot resolve their context, split or parameters differently.
+#[allow(clippy::too_many_arguments)]
 fn build_reader(
     hudi_configs: Arc<HudiConfigs>,
     storage: Arc<Storage>,
     base_file_path: &str,
     log_file_paths: Vec<String>,
     partition_path: String,
+    pushdown: Option<FilterPushdown>,
     data_schema: Option<SchemaRef>,
     completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
 ) -> Result<HoodieFileGroupReader> {
@@ -121,6 +130,16 @@ fn build_reader(
     // timeline; `None` leaves the gate a no-op, which is what a caller reading
     // from paths alone (the cxx bridge) can offer.
     context.completion_gate_inputs = completion_gate_inputs;
+    if let Some(pushdown) = pushdown {
+        // `repair_risk_columns` stays empty: the pushdown refuses any file 
that
+        // stores a filter column in a type other than the table's, which 
covers
+        // every file the logical timestamp repair would reinterpret.
+        context.row_filter_builder = Some(pushdown.row_filter);
+        context.row_group_selector = Some(pushdown.row_groups);
+        // A merge by row position pairs log records with base rows one to one,
+        // so nothing may drop records from either side before it.
+        context.mor_pk_safe = pushdown.mor_pk_safe && 
!context.should_merge_use_record_position;
+    }
     context.rebuild_record_context(partition_path.clone());
 
     let base_file_commit_time = base_file_path.and_then(base_file_commit_time);
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 51197f38..2d0baff4 100644
--- a/crates/core/src/file_group/reader_v2/reader_context.rs
+++ b/crates/core/src/file_group/reader_v2/reader_context.rs
@@ -127,8 +127,8 @@ pub struct ReaderContext {
     /// appear in one of those two, or it reads as unset.
     pub hoodie_reader_config: HashMap<String, String>,
     /// Optional parquet `RowFilter` builder for predicate pushdown into base
-    /// parquet files and parquet-format log blocks. Set by the FFI bridge from
-    /// the decoded substrait predicate; `None` when no predicate was pushed.
+    /// parquet files and parquet-format log blocks; `None` when no predicate 
was
+    /// pushed.
     ///
     /// Whether it actually gets installed is gated by whether the read merges:
     /// `HoodieFileGroupReader::base_read_pushdown_is_safe` for the base file
@@ -136,13 +136,10 @@ pub struct ReaderContext {
     /// [`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)
-    /// leaves it `None`, so a `Table` or DataFusion read pushes no predicate 
into
-    /// either the base file or a log block, and the primary-key-safety gate 
never
-    /// runs outside the test harness. Filters are applied after the merge 
instead.
-    /// Groundwork for a caller that supplies one; no performance claim about 
this
-    /// crate's reads rests on it.
+    /// leaves it `None`; a slice read sets it from the caller's filters
+    /// (`file_group::pushdown`), and the caller still applies those filters
+    /// after the merge.
     pub row_filter_builder: Option<RowFilterBuilder>,
     /// Optional row-group selector for pruning base parquet reads from footer
     /// statistics; `None` when no caller supplied one.
diff --git a/crates/core/tests/table_read_filter_pushdown_tests.rs 
b/crates/core/tests/table_read_filter_pushdown_tests.rs
new file mode 100644
index 00000000..11683704
--- /dev/null
+++ b/crates/core/tests/table_read_filter_pushdown_tests.rs
@@ -0,0 +1,211 @@
+/*
+ * 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.
+ */
+//! Filters pushed into the parquet reader must not change what a read returns,
+//! on copy-on-write and on merge-on-read tables whose log files update the
+//! filtered columns.
+
+use std::sync::Arc;
+
+use arrow_array::{Int64Array, RecordBatch, StringArray};
+use arrow_schema::{DataType, Field, Schema};
+use hudi_core::config::table::TableTypeValue;
+use hudi_core::table::{ReadOptions, Table};
+use tempfile::tempdir;
+
+fn batch(ids: &[&str], values: &[i64]) -> RecordBatch {
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Utf8, false),
+        Field::new("value", DataType::Int64, false),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(StringArray::from(ids.to_vec())),
+            Arc::new(Int64Array::from(values.to_vec())),
+        ],
+    )
+    .unwrap()
+}
+
+fn filters(filters: &[(&str, &str, &str)]) -> ReadOptions {
+    ReadOptions::new()
+        .with_filters(filters.iter().copied())
+        .unwrap()
+}
+
+async fn rows(table: &Table, options: &ReadOptions) -> Vec<(String, i64)> {
+    let mut out: Vec<(String, i64)> = table
+        .read(options)
+        .await
+        .unwrap()
+        .iter()
+        .flat_map(|b| {
+            let ids = b
+                .column_by_name("id")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let values = b
+                .column_by_name("value")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap();
+            ids.iter()
+                .zip(values.iter())
+                .map(|(i, v)| (i.unwrap().to_string(), v.unwrap()))
+                .collect::<Vec<_>>()
+        })
+        .collect();
+    out.sort();
+    out
+}
+
+/// Two file groups per table, with a few rows per parquet row group so the
+/// row-group selection has something to drop.
+async fn table(dir: &std::path::Path, table_type: TableTypeValue) -> Table {
+    let mut table = Table::create(dir.to_str().unwrap())
+        .with_table_name("t")
+        .with_record_key_fields(["id"])
+        .with_table_type(table_type)
+        .with_option("hoodie.parquet.small.file.limit", "0")
+        .with_option("hoodie.parquet.block.size", "2048")
+        .with_option("hoodie.copyonwrite.record.size.estimate", "1024")
+        .create()
+        .await
+        .unwrap();
+    table
+        .append([batch(&["a1", "a2", "a3", "a4"], &[1, 2, 3, 4])])
+        .await
+        .unwrap();
+    table
+        .append([batch(&["b1", "b2", "b3", "b4"], &[5, 6, 7, 8])])
+        .await
+        .unwrap();
+    Table::new(dir.to_str().unwrap()).await.unwrap()
+}
+
+#[tokio::test]
+async fn test_pushed_filters_return_the_same_rows_on_copy_on_write() {
+    let dir = tempdir().unwrap();
+    let table = table(dir.path(), TableTypeValue::CopyOnWrite).await;
+    assert_eq!(
+        rows(
+            &table,
+            &filters(&[("value", ">=", "3"), ("value", "<", "7")])
+        )
+        .await,
+        vec![
+            ("a3".to_string(), 3),
+            ("a4".to_string(), 4),
+            ("b1".to_string(), 5),
+            ("b2".to_string(), 6),
+        ]
+    );
+    assert_eq!(
+        rows(&table, &filters(&[("_hoodie_record_key", "IN", "a2,b4")])).await,
+        vec![("a2".to_string(), 2), ("b4".to_string(), 8)]
+    );
+    assert_eq!(rows(&table, &filters(&[("id", "=", "zz")])).await, vec![]);
+}
+
+/// A log file changes `value`; a filter on `value` must see the merged row,
+/// so it cannot drop base rows before the merge, while a filter on the key
+/// can.
+#[tokio::test]
+async fn test_pushed_filters_see_merged_rows_on_merge_on_read() {
+    let dir = tempdir().unwrap();
+    table(dir.path(), TableTypeValue::MergeOnRead).await;
+    let mut writer = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    writer
+        .upsert([batch(&["a2", "b3"], &[200, 300])])
+        .await
+        .unwrap();
+    let table = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+
+    // The old values are gone and the new ones match.
+    assert_eq!(rows(&table, &filters(&[("value", "=", "2")])).await, vec![]);
+    assert_eq!(
+        rows(&table, &filters(&[("value", ">", "100")])).await,
+        vec![("a2".to_string(), 200), ("b3".to_string(), 300)]
+    );
+    // Key filters are pushed into the base read and still merge the update.
+    assert_eq!(
+        rows(&table, &filters(&[("id", "IN", "a2,a3")])).await,
+        vec![("a2".to_string(), 200), ("a3".to_string(), 3)]
+    );
+    assert_eq!(
+        rows(
+            &table,
+            &filters(&[("id", "=", "b3"), ("value", "=", "300")])
+        )
+        .await,
+        vec![("b3".to_string(), 300)]
+    );
+}
+
+/// With an ordering field a log record can lose to the base row. A filter on
+/// the column it would have changed must then see the base value, which a
+/// filter pushed below the merge would have dropped.
+#[tokio::test]
+async fn test_a_losing_log_record_does_not_leak_through_a_pushed_filter() {
+    let dir = tempdir().unwrap();
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Utf8, false),
+        Field::new("value", DataType::Int64, false),
+        Field::new("ts", DataType::Int64, false),
+    ]));
+    let batch = |value: i64, ts: i64| {
+        RecordBatch::try_new(
+            schema.clone(),
+            vec![
+                Arc::new(StringArray::from(vec!["a1"])),
+                Arc::new(Int64Array::from(vec![value])),
+                Arc::new(Int64Array::from(vec![ts])),
+            ],
+        )
+        .unwrap()
+    };
+    let mut table = Table::create(dir.path().to_str().unwrap())
+        .with_table_name("t")
+        .with_record_key_fields(["id"])
+        .with_ordering_fields(["ts"])
+        .with_table_type(TableTypeValue::MergeOnRead)
+        .create()
+        .await
+        .unwrap();
+    table.append([batch(5, 10)]).await.unwrap();
+    table.upsert([batch(1, 5)]).await.unwrap();
+    let table = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    let slices = table.get_file_slices(&ReadOptions::new()).await.unwrap();
+    assert!(
+        slices.iter().any(|s| s.has_log_file()),
+        "the upsert went to a log"
+    );
+    assert_eq!(rows(&table, &filters(&[("value", "=", "1")])).await, vec![]);
+    assert_eq!(
+        rows(&table, &filters(&[("id", "=", "a1"), ("value", "=", 
"1")])).await,
+        vec![]
+    );
+    assert_eq!(
+        rows(&table, &filters(&[("value", "=", "5")])).await,
+        vec![("a1".to_string(), 5)]
+    );
+}

Reply via email to