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 3a031458 fix(core): decline base-read pushdown when a predicate reads 
a repaired column (#748)
3a031458 is described below

commit 3a03145816f23906151cd61976b44822be17e7ff
Author: Lin Liu <[email protected]>
AuthorDate: Fri Sep 4 18:17:37 2026 -0700

    fix(core): decline base-read pushdown when a predicate reads a repaired 
column (#748)
---
 crates/core/src/file_group/reader_v2/engine.rs     | 603 ++++++++++++++++++++-
 .../src/file_group/reader_v2/reader_context.rs     |  30 +
 crates/core/src/file_group/reader_v2/resolver.rs   |   3 +
 crates/core/src/schema/batch_evolution.rs          | 480 ++++++++++++++++
 crates/core/src/storage/mod.rs                     |  41 +-
 5 files changed, 1148 insertions(+), 9 deletions(-)

diff --git a/crates/core/src/file_group/reader_v2/engine.rs 
b/crates/core/src/file_group/reader_v2/engine.rs
index 4483abe2..afbbee94 100644
--- a/crates/core/src/file_group/reader_v2/engine.rs
+++ b/crates/core/src/file_group/reader_v2/engine.rs
@@ -812,7 +812,7 @@ impl HoodieFileGroupReader {
         // 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 {
+        let mut row_filter = if pushdown_is_safe {
             self.reader_context.row_filter_builder.clone()
         } else {
             if self.reader_context.row_filter_builder.is_some() {
@@ -824,7 +824,7 @@ impl HoodieFileGroupReader {
             }
             None
         };
-        let row_group_selector = if pushdown_is_safe {
+        let mut 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
@@ -932,6 +932,63 @@ impl HoodieFileGroupReader {
             present_len
         );
 
+        // Parquet evaluates a pushed predicate against the file's PHYSICAL 
values,
+        // before `project_batch_to_schema` runs. Sound only while a physical 
value
+        // means what its physical type says, which the apache/hudi#18132 
repair
+        // breaks: the file labels a tz-aware column micros while the stored 
i64 is
+        // MILLIS, so a millis-semantics literal reads those rows as 1970 and 
the
+        // filter drops rows that match. The post-scan filter cannot restore 
them.
+        //
+        // Two gates, cheapest first. `repair_risk_columns` was decided ONCE 
per scan
+        // from the table schema and the predicate's own referenced columns, 
and is
+        // empty unless the predicate touches a tz-aware millis column — so the
+        // common scan never reaches the footer comparison below and never 
loses
+        // pushdown. The footer schema itself is already fetched 
unconditionally
+        // above, so gate 1 buys predicate scoping and the per-file name walk, 
not
+        // avoided IO.
+        //
+        // The table side is `table_schema`, NOT `required_schema`: a filter 
column
+        // absent from the projection is still decoded and still misread, 
because a
+        // `RowFilter` builder derives its own `ProjectionMask` from the 
parquet
+        // schema rather than from `intersection`.
+        let repair_conflict =
+            if pushdown_is_safe && 
!self.reader_context.repair_risk_columns.is_empty() {
+                let table_side = self
+                    .schema_handler
+                    .table_schema
+                    .as_ref()
+                    .unwrap_or(&required_schema);
+                crate::schema::batch_evolution::reinterpreted_columns(
+                    &file_schema,
+                    table_side,
+                    &self.reader_context.repair_risk_columns,
+                )?
+            } else {
+                Vec::new()
+            };
+
+        // ONE verdict, both consumers, withdrawn in one block. That is the 
same
+        // property the merge-safety gate is bound once for, one layer in: an 
edit
+        // to the condition cannot leave the row-group selector behind, and 
pruning
+        // is the one that must not be left behind — it drops rows before 
anything
+        // downstream can see them.
+        if !repair_conflict.is_empty() {
+            let volume = self.storage.read_volume();
+            // Counted for every withdrawal; `row_group_selector_suppressed` 
can
+            // only speak for a selector the caller actually installed.
+            volume.record_pushdown_suppressed_by_repair();
+            if row_group_selector.is_some() {
+                volume.record_selector_suppressed();
+            }
+            log::debug!(
+                "base file '{path}' needs a value-reinterpreting logical-type 
repair \
+                 on {repair_conflict:?} — skipping parquet RowFilter pushdown 
and \
+                 row-group pruning (post-merge filter still runs)"
+            );
+            row_filter = None;
+            row_group_selector = None;
+        }
+
         let base_read_schema: SchemaRef = if use_position {
             let mut fields: Vec<arrow_schema::FieldRef> =
                 required_schema.fields().iter().cloned().collect();
@@ -1187,6 +1244,9 @@ pub struct HoodieFileGroupReaderBuilder {
     row_group_selector: Option<RowGroupSelector>,
     /// Set by `with_mor_pk_safe`; copied onto the cloned reader_context.
     mor_pk_safe: Option<bool>,
+    /// Set by `with_repair_risk_columns`; copied onto the cloned 
reader_context.
+    /// Absent leaves the repair guard OFF.
+    repair_risk_columns: Option<Vec<String>>,
 }
 
 /// Reached only from the test harness — see the builder's own note.
@@ -1236,6 +1296,9 @@ impl HoodieFileGroupReaderBuilder {
     ///
     /// The builder is also visible to the parquet log block decoder via the
     /// same `reader_context` channel.
+    ///
+    /// Pair this with [`Self::with_repair_risk_columns`], or the repair guard 
is
+    /// off and a base file mislabelling a predicate column over-drops rows.
     pub fn with_row_filter_builder(mut self, b: RowFilterBuilder) -> Self {
         self.row_filter_builder = Some(b);
         self
@@ -1265,6 +1328,20 @@ impl HoodieFileGroupReaderBuilder {
         self
     }
 
+    /// Arm the value-reinterpreting repair guard with the predicate columns 
the
+    /// apache/hudi#18132 logical-type repair could make a pushed filter 
misread.
+    ///
+    /// Required alongside [`Self::with_row_filter_builder`] whenever the 
table may
+    /// hold legacy base files labelling a tz-aware column micros while the 
stored
+    /// i64 is millis. Left unset the guard is OFF and such a file over-drops 
rows —
+    /// this is not a perf knob. Compute via
+    /// [`crate::schema::batch_evolution::repair_risk_columns`]; the empty vec 
is the
+    /// explicit "no column is at risk".
+    pub fn with_repair_risk_columns(mut self, columns: Vec<String>) -> Self {
+        self.repair_risk_columns = Some(columns);
+        self
+    }
+
     pub fn build(self) -> Result<HoodieFileGroupReader> {
         let reader_context = self
             .reader_context
@@ -1283,6 +1360,7 @@ impl HoodieFileGroupReaderBuilder {
         let reader_context = if self.row_filter_builder.is_some()
             || self.row_group_selector.is_some()
             || self.mor_pk_safe.is_some()
+            || self.repair_risk_columns.is_some()
         {
             let mut updated = (*reader_context).clone();
             if let Some(b) = self.row_filter_builder {
@@ -1294,6 +1372,9 @@ impl HoodieFileGroupReaderBuilder {
             if let Some(s) = self.mor_pk_safe {
                 updated.mor_pk_safe = s;
             }
+            if let Some(cols) = self.repair_risk_columns {
+                updated.repair_risk_columns = cols;
+            }
             Arc::new(updated)
         } else {
             reader_context
@@ -2589,4 +2670,522 @@ mod tests {
             "the streamed read must return the same rows as the single-batch 
read"
         );
     }
+
+    // ── pushdown vs. the apache/hudi#18132 logical-type repair 
────────────────
+
+    /// A `ts > threshold` row filter that normalises the column to NANOSECONDS
+    /// from its own DECLARED unit — the shape an engine's timestamp comparison
+    /// takes when it reconciles a literal against the column type parquet 
reports.
+    /// Counts its own invocations, so a test can assert the filter was never 
even
+    /// built rather than inferring it from rows.
+    fn nanos_gt_filter_builder(
+        column: &'static str,
+        threshold_nanos: i64,
+        invocations: Arc<std::sync::atomic::AtomicUsize>,
+    ) -> RowFilterBuilder {
+        use parquet::arrow::ProjectionMask;
+        use parquet::arrow::arrow_reader::{ArrowPredicateFn, RowFilter};
+        use std::sync::atomic::Ordering::Relaxed;
+        Arc::new(move |parquet_schema, _projected_schema| {
+            invocations.fetch_add(1, Relaxed);
+            let root = parquet_schema.root_schema();
+            let idx = root.get_fields().iter().position(|f| f.name() == 
column)?;
+            let mask = ProjectionMask::roots(parquet_schema, [idx]);
+            let predicate = ArrowPredicateFn::new(mask, move |batch: 
RecordBatch| {
+                use arrow_array::cast::AsArray;
+                use arrow_array::types::{TimestampMicrosecondType, 
TimestampMillisecondType};
+                let col = batch.column_by_name(column).ok_or_else(|| {
+                    arrow_schema::ArrowError::ComputeError(format!(
+                        "predicate column '{column}' missing from the 
predicate batch"
+                    ))
+                })?;
+                // Scale the raw i64 to nanos using the unit the column 
DECLARES.
+                // That declaration is precisely what a mislabelled file gets 
wrong,
+                // so the scaling inherits the lie.
+                let (values, per_unit): (Vec<i64>, i64) = match 
col.data_type() {
+                    
arrow_schema::DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, _) => {
+                        let a = col.as_primitive::<TimestampMicrosecondType>();
+                        ((0..a.len()).map(|i| a.value(i)).collect(), 1_000)
+                    }
+                    
arrow_schema::DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, _) => {
+                        let a = col.as_primitive::<TimestampMillisecondType>();
+                        ((0..a.len()).map(|i| a.value(i)).collect(), 1_000_000)
+                    }
+                    other => {
+                        return 
Err(arrow_schema::ArrowError::ComputeError(format!(
+                            "unsupported predicate column type {other}"
+                        )));
+                    }
+                };
+                Ok(arrow_array::BooleanArray::from_iter(
+                    values.iter().map(|v| Some(v * per_unit > 
threshold_nanos)),
+                ))
+            });
+            Some(RowFilter::new(vec![Box::new(predicate)]))
+        })
+    }
+
+    /// Like [`test_file_group_reader_for_base_file`], but also installs a row
+    /// filter builder so the base read exercises the pushdown path.
+    async fn test_file_group_reader_with_row_filter(
+        dir: &std::path::Path,
+        base_name: &str,
+        required: SchemaRef,
+        row_filter_builder: RowFilterBuilder,
+        row_group_selector: Option<RowGroupSelector>,
+        repair_risk_columns: &[&str],
+    ) -> HoodieFileGroupReader {
+        let mut reader = test_file_group_reader_for_base_file(dir, base_name, 
required).await;
+        let mut context = (*reader.reader_context).clone();
+        context.row_filter_builder = Some(row_filter_builder);
+        context.row_group_selector = row_group_selector;
+        // What `batch_evolution::repair_risk_columns` would have produced for 
this
+        // predicate against this table schema — the gate that arms the 
per-file check.
+        context.repair_risk_columns = repair_risk_columns.iter().map(|c| 
c.to_string()).collect();
+        reader.reader_context = Arc::new(context);
+        reader
+    }
+
+    /// 2020-01-01T00:00:00Z — the threshold the failing fixtures straddle.
+    const THRESHOLD_NANOS: i64 = 1_577_836_800_000_000_000;
+    /// 2020-01-01T00:00:00.001Z as MILLIS — above the threshold.
+    const ABOVE_MS: i64 = 1_577_836_800_001;
+    /// 2019-12-31T23:59:59.999Z as MILLIS — below it.
+    const BELOW_MS: i64 = 1_577_836_799_999;
+
+    fn ts_field(name: &str, unit: arrow_schema::TimeUnit) -> 
arrow_schema::Field {
+        arrow_schema::Field::new(
+            name,
+            arrow_schema::DataType::Timestamp(unit, Some("UTC".into())),
+            true,
+        )
+    }
+
+    /// The table's view of the straddling file: `ts` is tz-aware MILLIS, 
which is
+    /// what the stored i64s have always been.
+    fn straddling_table_schema() -> SchemaRef {
+        Arc::new(arrow_schema::Schema::new(vec![
+            arrow_schema::Field::new("_hoodie_record_key", 
arrow_schema::DataType::Utf8, true),
+            ts_field("ts", arrow_schema::TimeUnit::Millisecond),
+        ]))
+    }
+
+    /// Write a two-row base file whose `ts` column is DECLARED with 
`declared_unit`
+    /// while its values are always the millisecond counts above. When
+    /// `declared_unit` is micros this is the apache/hudi#18132 shape: the 
label is
+    /// a lie and the repair has to reinterpret it on read.
+    fn write_straddling_base_file(
+        dir: &std::path::Path,
+        name: &str,
+        declared_unit: arrow_schema::TimeUnit,
+    ) {
+        let file_schema = Arc::new(arrow_schema::Schema::new(vec![
+            arrow_schema::Field::new("_hoodie_record_key", 
arrow_schema::DataType::Utf8, true),
+            ts_field("ts", declared_unit),
+        ]));
+        let ts: arrow_array::ArrayRef = match declared_unit {
+            arrow_schema::TimeUnit::Microsecond => Arc::new(
+                arrow_array::TimestampMicrosecondArray::from(vec![ABOVE_MS, 
BELOW_MS])
+                    .with_timezone("UTC"),
+            ),
+            _ => Arc::new(
+                arrow_array::TimestampMillisecondArray::from(vec![ABOVE_MS, 
BELOW_MS])
+                    .with_timezone("UTC"),
+            ),
+        };
+        let batch = RecordBatch::try_new(
+            file_schema,
+            vec![
+                Arc::new(arrow_array::StringArray::from(vec!["k1", "k2"])),
+                ts,
+            ],
+        )
+        .unwrap();
+        write_parquet_file(dir, name, &batch);
+    }
+
+    /// THE REGRESSION. The file declares `ts` as tz-aware micros while the 
stored
+    /// i64s are MILLIS, so a nanos-normalised predicate reads them as 1970 and
+    /// `ts > 2020-01-01` matches nothing. The post-scan filter cannot restore 
the
+    /// rows the scan already dropped.
+    #[tokio::test]
+    async fn 
base_read_declines_pushdown_when_the_file_needs_a_reinterpreting_repair() {
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        write_straddling_base_file(
+            tmp.path(),
+            base_name,
+            arrow_schema::TimeUnit::Microsecond, // the LIE
+        );
+
+        let required = straddling_table_schema();
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("ts", THRESHOLD_NANOS, 
invocations.clone());
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            required.clone(),
+            builder,
+            None,
+            &["ts"],
+        )
+        .await;
+        // The existing merge gate is satisfied: no log files, so nothing 
merges.
+        assert!(
+            reader.base_read_pushdown_is_safe(),
+            "a slice with no log files clears the merge gate; the repair check 
\
+             is what must decline this read"
+        );
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(
+            invocations.load(Relaxed),
+            0,
+            "the row filter must never even be BUILT for a file whose physical 
\
+             timestamp labelling is repaired on read"
+        );
+        assert_eq!(out.schema(), required);
+        assert_eq!(
+            out.num_rows(),
+            2,
+            "both rows must reach the post-scan filter; dropping one inside 
the \
+             scan is unrecoverable"
+        );
+        let ts = out
+            .column(1)
+            .as_any()
+            .downcast_ref::<arrow_array::TimestampMillisecondArray>()
+            .expect("the repair must relabel the column to millis");
+        assert_eq!(
+            (ts.value(0), ts.value(1)),
+            (ABOVE_MS, BELOW_MS),
+            "and it must relabel the i64, not rescale it"
+        );
+    }
+
+    /// The other half of the rule. Same values and predicate, but the file 
declares
+    /// the unit it actually uses, so no repair applies and pushdown must 
survive —
+    /// otherwise the guard is a blanket regression on every well-formed table.
+    #[tokio::test]
+    async fn base_read_keeps_pushdown_when_the_file_is_honestly_labelled() {
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        write_straddling_base_file(tmp.path(), base_name, 
arrow_schema::TimeUnit::Millisecond);
+
+        let required = straddling_table_schema();
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("ts", THRESHOLD_NANOS, 
invocations.clone());
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            required.clone(),
+            builder,
+            None,
+            &["ts"],
+        )
+        .await;
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(
+            invocations.load(Relaxed),
+            1,
+            "an honestly labelled file must keep its pushdown — the guard keys 
on \
+             the FILE's own schema, not on the table's"
+        );
+        assert_eq!(
+            out.num_rows(),
+            1,
+            "the pushed predicate keeps only the row above the threshold"
+        );
+    }
+
+    /// The narrowing. A file mislabels `ts`, but the predicate reads `other`, 
so
+    /// nothing the predicate touches is misread and pushdown must be kept. 
Without
+    /// the per-column scoping this file would lose pushdown for a predicate 
the
+    /// repair cannot affect.
+    #[tokio::test]
+    async fn 
base_read_keeps_pushdown_for_a_predicate_on_an_unaffected_column() {
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        // `ts` mislabelled micros; `other` is honestly labelled millis.
+        let file_schema = Arc::new(arrow_schema::Schema::new(vec![
+            ts_field("ts", arrow_schema::TimeUnit::Microsecond),
+            ts_field("other", arrow_schema::TimeUnit::Millisecond),
+        ]));
+        let batch = RecordBatch::try_new(
+            file_schema,
+            vec![
+                Arc::new(
+                    
arrow_array::TimestampMicrosecondArray::from(vec![ABOVE_MS, BELOW_MS])
+                        .with_timezone("UTC"),
+                ),
+                Arc::new(
+                    
arrow_array::TimestampMillisecondArray::from(vec![ABOVE_MS, BELOW_MS])
+                        .with_timezone("UTC"),
+                ),
+            ],
+        )
+        .unwrap();
+        write_parquet_file(tmp.path(), base_name, &batch);
+
+        let required: SchemaRef = Arc::new(arrow_schema::Schema::new(vec![
+            ts_field("ts", arrow_schema::TimeUnit::Millisecond),
+            ts_field("other", arrow_schema::TimeUnit::Millisecond),
+        ]));
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("other", THRESHOLD_NANOS, 
invocations.clone());
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            required,
+            builder,
+            None,
+            // Gate 1 saw only `other`: it is the sole column the predicate 
reads.
+            &["other"],
+        )
+        .await;
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(
+            invocations.load(Relaxed),
+            1,
+            "a mislabelled column the predicate never reads must not cost 
pushdown"
+        );
+        assert_eq!(out.num_rows(), 1);
+    }
+
+    /// The unarmed gate. Same mislabelled file and same predicate column, but 
gate 1
+    /// reported nothing at risk — the case of every table Spark wrote with 
micros.
+    /// The per-file check must not run at all, so pushdown survives.
+    #[tokio::test]
+    async fn base_read_keeps_pushdown_when_no_predicate_column_is_at_risk() {
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        write_straddling_base_file(tmp.path(), base_name, 
arrow_schema::TimeUnit::Microsecond);
+
+        let required = straddling_table_schema();
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("ts", THRESHOLD_NANOS, 
invocations.clone());
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            required,
+            builder,
+            None,
+            &[], // gate 1 disarmed
+        )
+        .await;
+        let _ = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(
+            invocations.load(Relaxed),
+            1,
+            "an empty repair_risk_columns must skip the per-file check 
entirely"
+        );
+    }
+
+    /// The table side of gate 2 is the TABLE schema, not the projection. A 
pushed
+    /// predicate reads its columns whether or not they were projected, 
because the
+    /// `RowFilter` builder derives its own `ProjectionMask` from the parquet 
schema.
+    /// Here `ts` is mislabelled and absent from `required_schema`; reading the
+    /// projection instead of the table schema would find nothing and push 
anyway.
+    #[tokio::test]
+    async fn base_read_declines_pushdown_for_an_unprojected_predicate_column() 
{
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        write_straddling_base_file(tmp.path(), base_name, 
arrow_schema::TimeUnit::Microsecond);
+
+        // Projection keeps only the key; `ts` is filtered on but never 
returned.
+        let required: SchemaRef =
+            Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
+                "_hoodie_record_key",
+                arrow_schema::DataType::Utf8,
+                true,
+            )]));
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("ts", THRESHOLD_NANOS, 
invocations.clone());
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            required,
+            builder,
+            None,
+            &["ts"],
+        )
+        .await;
+        reader.schema_handler.table_schema = Some(straddling_table_schema());
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(
+            invocations.load(Relaxed),
+            0,
+            "the guard must consult the TABLE schema; a filter column outside 
the \
+             projection is still decoded and still misread"
+        );
+        assert_eq!(out.num_rows(), 2);
+    }
+
+    /// A withdrawal takes the row-group selector with it, and is counted on 
both
+    /// counters: `row_group_selector_suppressed` so the existing "installed 
but
+    /// never passed down" question stays answerable, and
+    /// `pushdown_suppressed_by_repair` so its cause is separable from a
+    /// merge-gate refusal.
+    #[tokio::test]
+    async fn repair_suppression_counts_the_row_group_selector() {
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        write_straddling_base_file(tmp.path(), base_name, 
arrow_schema::TimeUnit::Microsecond);
+
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("ts", THRESHOLD_NANOS, 
invocations.clone());
+        let selector_calls = Arc::new(AtomicUsize::new(0));
+        let seen = selector_calls.clone();
+        let selector: RowGroupSelector = Arc::new(move |_| {
+            seen.fetch_add(1, Relaxed);
+            Some(vec![0])
+        });
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            straddling_table_schema(),
+            builder,
+            Some(selector),
+            &["ts"],
+        )
+        .await;
+        let volume = reader.storage.read_volume();
+        let out = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(out.num_rows(), 2);
+        assert_eq!(selector_calls.load(Relaxed), 0, "the selector never ran");
+        assert_eq!(volume.row_group_selector_calls.load(Relaxed), 0);
+        assert_eq!(volume.row_group_selector_suppressed.load(Relaxed), 1);
+        assert_eq!(
+            volume.pushdown_suppressed_by_repair.load(Relaxed),
+            1,
+            "the cause must be separable from a merge-gate refusal"
+        );
+    }
+
+    /// The row-filter-only case, which `row_group_selector_suppressed` 
structurally
+    /// cannot see: no selector was ever installed, so that counter stays zero 
while
+    /// pushdown was still withdrawn.
+    #[tokio::test]
+    async fn repair_suppression_is_counted_without_a_row_group_selector() {
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        write_straddling_base_file(tmp.path(), base_name, 
arrow_schema::TimeUnit::Microsecond);
+
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("ts", THRESHOLD_NANOS, 
invocations);
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            straddling_table_schema(),
+            builder,
+            None,
+            &["ts"],
+        )
+        .await;
+        let volume = reader.storage.read_volume();
+        let _ = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(
+            volume.row_group_selector_suppressed.load(Relaxed),
+            0,
+            "no selector was installed, so that counter cannot speak for this 
case"
+        );
+        assert_eq!(volume.pushdown_suppressed_by_repair.load(Relaxed), 1);
+    }
+
+    /// And it must stay at zero when pushdown survives, or the counter cannot
+    /// distinguish "a file was withdrawn" from "the scan ran".
+    #[tokio::test]
+    async fn repair_suppression_is_not_counted_when_pushdown_survives() {
+        use std::sync::atomic::AtomicUsize;
+        use std::sync::atomic::Ordering::Relaxed;
+
+        let tmp = tempfile::tempdir().unwrap();
+        let base_name = "f1-0_0-1-1_001.parquet";
+        write_straddling_base_file(tmp.path(), base_name, 
arrow_schema::TimeUnit::Millisecond);
+
+        let invocations = Arc::new(AtomicUsize::new(0));
+        let builder = nanos_gt_filter_builder("ts", THRESHOLD_NANOS, 
invocations);
+
+        let mut reader = test_file_group_reader_with_row_filter(
+            tmp.path(),
+            base_name,
+            straddling_table_schema(),
+            builder,
+            None,
+            &["ts"],
+        )
+        .await;
+        let volume = reader.storage.read_volume();
+        let _ = 
drain_base_source(reader.base_file_source().await.unwrap()).await;
+
+        assert_eq!(volume.pushdown_suppressed_by_repair.load(Relaxed), 0);
+    }
+
+    #[test]
+    fn builder_routes_repair_risk_columns_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_filter_builder(make_row_filter_builder())
+            .with_repair_risk_columns(vec!["ts".to_string()])
+            .build()
+            .unwrap();
+        assert_eq!(
+            reader.reader_context.repair_risk_columns,
+            vec!["ts".to_string()],
+            "with_repair_risk_columns should land on reader_context"
+        );
+    }
+
+    #[test]
+    fn builder_leaves_repair_risk_columns_empty_by_default() {
+        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_filter_builder(make_row_filter_builder())
+            .build()
+            .unwrap();
+        assert!(
+            reader.reader_context.repair_risk_columns.is_empty(),
+            "unset must leave the guard disarmed, not populated by accident"
+        );
+    }
 }
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 883d5ccd..51197f38 100644
--- a/crates/core/src/file_group/reader_v2/reader_context.rs
+++ b/crates/core/src/file_group/reader_v2/reader_context.rs
@@ -180,6 +180,34 @@ pub struct ReaderContext {
     /// post-merge evaluation (handled by the caller — Velox/Spark — above
     /// the FG reader).
     pub mor_pk_safe: bool,
+    /// Predicate columns the TABLE schema declares tz-aware millis, i.e. the 
only
+    /// columns the apache/hudi#18132 logical-type repair can reinterpret on 
read.
+    ///
+    /// Parquet evaluates a pushed predicate against a file's PHYSICAL values,
+    /// before `project_batch_to_schema` runs. That is sound only while a 
physical
+    /// value means what its physical type says, which the repair breaks: a 
legacy
+    /// file labels the column micros while the stored i64 is millis, so a
+    /// millis-semantics literal reads those rows as 1970 and the scan drops 
rows
+    /// that match. A post-scan filter cannot restore them.
+    ///
+    /// Computed once per scan by whoever supplies 
[`Self::row_filter_builder`], from
+    /// that predicate's referenced columns and the table schema, via
+    /// [`crate::schema::batch_evolution::repair_risk_columns`]; routed here by
+    /// 
[`HoodieFileGroupReaderBuilder::with_repair_risk_columns`](crate::file_group::reader_v2::HoodieFileGroupReaderBuilder::with_repair_risk_columns).
+    /// **Empty on every table whose predicate touches no tz-aware millis 
column,
+    /// which is the common case**; the base read then skips the per-file 
footer
+    /// comparison entirely and keeps all pushdown.
+    ///
+    /// Non-empty only arms the check. Whether a given file actually mislabels 
one
+    /// of these columns is decided per file against its footer schema, in
+    /// `HoodieFileGroupReader::make_base_file_source`.
+    ///
+    /// Empty is also the default, so a caller that pushes a filter without 
setting
+    /// this gets no guard. That is deliberate — the reader cannot derive a
+    /// predicate's columns from an opaque [`RowFilterBuilder`] — and it is why
+    /// `with_repair_risk_columns` is documented as required alongside
+    /// `with_row_filter_builder` rather than as a tuning knob.
+    pub repair_risk_columns: Vec<String>,
     /// Gate-3 completed/inflight inputs (completed/inflight/archived sets).
     /// Carried here mirroring [`Self::instant_range`]. `Some` only when the 
caller holds a
     /// timeline *and* the table version is below 8 — see
@@ -230,6 +258,7 @@ impl std::fmt::Debug for ReaderContext {
                 &self.row_group_selector.as_ref().map(|_| "<closure>"),
             )
             .field("mor_pk_safe", &self.mor_pk_safe)
+            .field("repair_risk_columns", &self.repair_risk_columns)
             .field("completion_gate_inputs", &self.completion_gate_inputs)
             .finish()
     }
@@ -338,6 +367,7 @@ impl ReaderContext {
             row_group_selector: None,
             key_predicate: None,
             mor_pk_safe: false,
+            repair_risk_columns: Vec::new(),
             completion_gate_inputs: None,
         }
     }
diff --git a/crates/core/src/file_group/reader_v2/resolver.rs 
b/crates/core/src/file_group/reader_v2/resolver.rs
index 89076f3a..e053b79f 100644
--- a/crates/core/src/file_group/reader_v2/resolver.rs
+++ b/crates/core/src/file_group/reader_v2/resolver.rs
@@ -103,6 +103,9 @@ pub(crate) fn resolve_reader_context(
         row_group_selector: None,
         key_predicate: None,
         mor_pk_safe: false,
+        // No pushed predicate, so no column can be misread by one and the
+        // per-file repair check never arms.
+        repair_risk_columns: Vec::new(),
         // The table-version < 8 completion gate needs a timeline the caller
         // has not loaded; leaving it unset keeps the gate a no-op.
         completion_gate_inputs: None,
diff --git a/crates/core/src/schema/batch_evolution.rs 
b/crates/core/src/schema/batch_evolution.rs
index 6273773d..aaae693c 100644
--- a/crates/core/src/schema/batch_evolution.rs
+++ b/crates/core/src/schema/batch_evolution.rs
@@ -93,6 +93,138 @@ pub(crate) fn index_of_ci(schema: &arrow_schema::Schema, 
name: &str) -> Result<O
     Ok(found)
 }
 
+/// True when evolving `file` → `table` REINTERPRETS the stored buffer instead 
of
+/// preserving what the value means.
+///
+/// Only the apache/hudi#18132 arm below does this: it relabels the i64 
because the
+/// value was always millis and only the label was wrong, so for that pairing 
alone
+/// the physical value does not mean what the physical type says. Every other 
arm
+/// (int widening, decimal rescale, millis→micros cast, the NTZ divide) 
denotes the
+/// same logical value before and after.
+///
+/// That distinction is what makes base-read predicate pushdown sound — see
+/// `HoodieFileGroupReader::make_base_file_source`.
+fn is_value_reinterpreting(file: &DataType, table: &DataType) -> bool {
+    matches!(
+        (file, table),
+        (
+            DataType::Timestamp(TimeUnit::Microsecond, Some(_)),
+            DataType::Timestamp(TimeUnit::Millisecond, Some(_)),
+        )
+    )
+}
+
+/// [`is_value_reinterpreting`] lifted through the container arms, mirroring 
the
+/// recursion in [`evolve_array`] so a nested affected field is not missed.
+/// Container drift returns `false`; the read itself rejects that pairing.
+fn pair_is_value_reinterpreting(file: &DataType, table: &DataType) -> bool {
+    if is_value_reinterpreting(file, table) {
+        return true;
+    }
+    match (file, table) {
+        (DataType::Struct(ff), DataType::Struct(tf)) => tf.iter().any(|t| {
+            ff.iter()
+                .find(|f| f.name().eq_ignore_ascii_case(t.name()))
+                .is_some_and(|f| pair_is_value_reinterpreting(f.data_type(), 
t.data_type()))
+        }),
+        (DataType::List(f), DataType::List(t))
+        | (DataType::LargeList(f), DataType::LargeList(t))
+        | (DataType::Map(f, _), DataType::Map(t, _)) => {
+            pair_is_value_reinterpreting(f.data_type(), t.data_type())
+        }
+        _ => false,
+    }
+}
+
+/// The TABLE half of [`is_value_reinterpreting`], recursed through containers.
+///
+/// The repair arm fires only when the table side is tz-aware millis, so a 
column
+/// the table declares as anything else can never carry the #18132 mislabel — 
no
+/// matter what any file says. That makes this decidable from the table schema
+/// alone, without opening a file.
+fn is_repair_target(table: &DataType) -> bool {
+    match table {
+        DataType::Timestamp(TimeUnit::Millisecond, Some(_)) => true,
+        DataType::Struct(fields) => fields.iter().any(|f| 
is_repair_target(f.data_type())),
+        DataType::List(f) | DataType::LargeList(f) | DataType::Map(f, _) => {
+            is_repair_target(f.data_type())
+        }
+        _ => false,
+    }
+}
+
+/// Which of `predicate_columns` could ever be misread by a pushed predicate,
+/// judged from the TABLE schema alone.
+///
+/// Meant to be computed ONCE per scan by whoever supplies the predicate, and 
handed
+/// to the reader via 
[`HoodieFileGroupReaderBuilder::with_repair_risk_columns`]
+/// (crate::file_group::reader_v2). Two properties earn it that place:
+///
+/// * **It is usually empty.** Spark's `TimestampType` maps to micros, so a
+///   tz-aware *millis* column is the legacy shape the #18132 repair exists 
for.
+///   An empty result means no base read in the scan needs any per-file check,
+///   and no file loses pushdown.
+/// * **It is scoped to the predicate.** A mislabelled column the predicate 
never
+///   references cannot make the predicate wrong, so it must not cost pushdown.
+///   `predicate_columns` must therefore be the columns the expression 
references,
+///   not every column in the schema the predicate was compiled against.
+///
+/// A name that resolves ambiguously is reported AS at risk rather than 
raising:
+/// this decides only whether to push a predicate, and over-reporting costs
+/// pushdown while under-reporting drops rows.
+pub fn repair_risk_columns(
+    table_schema: &arrow_schema::Schema,
+    predicate_columns: &[String],
+) -> Vec<String> {
+    predicate_columns
+        .iter()
+        .filter(|name| match index_of_ci(table_schema, name) {
+            Ok(Some(idx)) => 
is_repair_target(table_schema.fields()[idx].data_type()),
+            Ok(None) => false,
+            Err(_) => true,
+        })
+        .cloned()
+        .collect()
+}
+
+/// Which of `candidates` this file actually mislabels, i.e. evolving it to
+/// `table_schema` would reinterpret the buffer rather than preserve its 
meaning.
+///
+/// `candidates` is [`repair_risk_columns`]'s output, so this walks a handful 
of
+/// named columns rather than the whole footer schema. A candidate missing from
+/// either schema is skipped: absent from the file there is nothing to misread,
+/// and absent from the table there is no repair to reinterpret it. Names come
+/// back in the FILE's spelling, which is how a pushed predicate addresses the
+/// parquet column.
+///
+/// The table side is deliberately the table schema, not the projected one: a 
pushed
+/// predicate reads its columns whether or not they were projected, because the
+/// `RowFilter` builder derives its own `ProjectionMask` from the parquet 
schema
+/// rather than from the read's projection.
+pub(crate) fn reinterpreted_columns(
+    file_schema: &arrow_schema::Schema,
+    table_schema: &arrow_schema::Schema,
+    candidates: &[String],
+) -> Result<Vec<String>> {
+    let mut out = Vec::with_capacity(candidates.len());
+    for name in candidates {
+        let (Some(fi), Some(ti)) = (
+            index_of_ci(file_schema, name)?,
+            index_of_ci(table_schema, name)?,
+        ) else {
+            continue;
+        };
+        let file_field = &file_schema.fields()[fi];
+        if pair_is_value_reinterpreting(
+            file_field.data_type(),
+            table_schema.fields()[ti].data_type(),
+        ) {
+            out.push(file_field.name().clone());
+        }
+    }
+    Ok(out)
+}
+
 /// True for any nested/container Arrow type the recursion arms care about.
 /// Matching variants (List/Struct/Map) are handled by the recursion arms above
 /// the guard; this catches everything else (LargeList, FixedSizeList, and any
@@ -1256,4 +1388,352 @@ mod tests {
             &[5_000_000_000.0f64]
         );
     }
+
+    // The two pushdown classifiers. Flagging too little drops rows silently;
+    // flagging too much only costs pushdown. These pin both directions.
+
+    fn ts(unit: TimeUnit, tz: Option<&str>) -> DataType {
+        DataType::Timestamp(unit, tz.map(Into::into))
+    }
+
+    /// Every file column is offered as a candidate, so these pin the per-file 
rule
+    /// itself. `repair_risk_columns` decides which candidates a real scan 
supplies.
+    fn reinterpreted(file: Vec<Field>, required: Vec<Field>) -> Vec<String> {
+        let file_schema = Schema::new(file);
+        let candidates: Vec<String> = file_schema
+            .fields()
+            .iter()
+            .map(|f| f.name().clone())
+            .collect();
+        super::reinterpreted_columns(&file_schema, &Schema::new(required), 
&candidates).unwrap()
+    }
+
+    fn risk(table: Vec<Field>, predicate_columns: &[&str]) -> Vec<String> {
+        let cols: Vec<String> = predicate_columns.iter().map(|c| 
c.to_string()).collect();
+        super::repair_risk_columns(&Schema::new(table), &cols)
+    }
+
+    #[test]
+    fn 
repair_risk_columns_is_empty_when_the_predicate_touches_no_millis_column() {
+        // THE GATE THAT PAYS FOR ITSELF. A predicate over honest columns arms
+        // nothing, so no base read in the scan opens a footer for this check 
and
+        // no file loses pushdown. This is the common case on any table Spark 
wrote.
+        assert!(
+            risk(
+                vec![
+                    Field::new("id", DataType::Int64, true),
+                    Field::new("ts", ts(TimeUnit::Microsecond, Some("UTC")), 
true),
+                ],
+                &["id", "ts"],
+            )
+            .is_empty(),
+            "a table declaring micros can never be the TARGET of the #18132 
repair"
+        );
+    }
+
+    #[test]
+    fn repair_risk_columns_flags_only_the_predicate_columns_at_risk() {
+        // Scope is the predicate, not the table. `other` is at risk but 
unreferenced,
+        // so it must not cost this scan its pushdown.
+        assert_eq!(
+            risk(
+                vec![
+                    Field::new("ts", ts(TimeUnit::Millisecond, Some("UTC")), 
true),
+                    Field::new("other", ts(TimeUnit::Millisecond, 
Some("UTC")), true),
+                    Field::new("id", DataType::Int64, true),
+                ],
+                &["ts", "id"],
+            ),
+            vec!["ts".to_string()]
+        );
+    }
+
+    #[test]
+    fn repair_risk_columns_ignores_ntz_and_sees_through_containers() {
+        // NTZ millis is not the repair's target (it matches only the tz-aware
+        // logical classes), while a tz-aware millis field nested in a struct 
is.
+        assert!(
+            risk(
+                vec![Field::new("ntz", ts(TimeUnit::Millisecond, None), true)],
+                &["ntz"],
+            )
+            .is_empty()
+        );
+        let nested = DataType::Struct(
+            vec![Field::new(
+                "inner",
+                ts(TimeUnit::Millisecond, Some("UTC")),
+                true,
+            )]
+            .into(),
+        );
+        assert_eq!(
+            risk(vec![Field::new("s", nested, true)], &["s"]),
+            vec!["s".to_string()]
+        );
+    }
+
+    #[test]
+    fn repair_risk_columns_matches_names_case_insensitively() {
+        assert_eq!(
+            risk(
+                vec![Field::new(
+                    "TS",
+                    ts(TimeUnit::Millisecond, Some("UTC")),
+                    true
+                )],
+                &["ts"],
+            ),
+            vec!["ts".to_string()]
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_checks_only_the_candidates_it_is_given() {
+        // The per-file walk is scoped to the risk set. A mislabelled column 
the
+        // predicate never references is not a candidate, so it must not be
+        // reported -- it cannot make the predicate wrong.
+        let file = Schema::new(vec![
+            Field::new("ts", ts(TimeUnit::Microsecond, Some("UTC")), true),
+            Field::new("unreferenced", ts(TimeUnit::Microsecond, Some("UTC")), 
true),
+        ]);
+        let required = Schema::new(vec![
+            Field::new("ts", ts(TimeUnit::Millisecond, Some("UTC")), true),
+            Field::new("unreferenced", ts(TimeUnit::Millisecond, Some("UTC")), 
true),
+        ]);
+        assert_eq!(
+            super::reinterpreted_columns(&file, &required, 
&["ts".to_string()]).unwrap(),
+            vec!["ts".to_string()],
+            "only the candidate is reported, though both columns are 
mislabelled"
+        );
+        assert!(
+            super::reinterpreted_columns(&file, &required, &[])
+                .unwrap()
+                .is_empty(),
+            "no candidates means no work and no refusal"
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_skips_a_candidate_missing_from_either_schema() {
+        // Absent from the file: nothing to misread. Absent from required: 
never
+        // projected, so never repaired. Neither may panic on the index lookup.
+        let file = Schema::new(vec![Field::new(
+            "ts",
+            ts(TimeUnit::Microsecond, Some("UTC")),
+            true,
+        )]);
+        let required = Schema::new(vec![Field::new(
+            "ts",
+            ts(TimeUnit::Millisecond, Some("UTC")),
+            true,
+        )]);
+        let absent = ["nope".to_string()];
+        assert!(
+            super::reinterpreted_columns(&file, &required, &absent)
+                .unwrap()
+                .is_empty()
+        );
+        assert!(
+            super::reinterpreted_columns(&Schema::empty(), &required, 
&["ts".to_string()])
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_flags_the_hudi_18132_pair() {
+        // File says tz-aware micros, table says tz-aware millis, stored i64 
was
+        // millis all along — the only pairing a predicate can misread.
+        assert_eq!(
+            reinterpreted(
+                vec![Field::new(
+                    "ts",
+                    ts(TimeUnit::Microsecond, Some("UTC")),
+                    true
+                )],
+                vec![Field::new(
+                    "ts",
+                    ts(TimeUnit::Millisecond, Some("UTC")),
+                    true
+                )],
+            ),
+            vec!["ts".to_string()]
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_flags_the_pair_across_differing_timezones() {
+        // The repair arm accepts a tz mismatch (it warns, then reinterprets, 
since
+        // the i64 epoch is instant-preserving). The rule must agree, or a 
predicate
+        // would be pushed into a read the repair still rewrites.
+        assert_eq!(
+            reinterpreted(
+                vec![Field::new(
+                    "ts",
+                    ts(TimeUnit::Microsecond, Some("UTC")),
+                    true
+                )],
+                vec![Field::new(
+                    "ts",
+                    ts(TimeUnit::Millisecond, Some("America/New_York")),
+                    true
+                )],
+            ),
+            vec!["ts".to_string()]
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_reports_every_affected_column() {
+        // A file can carry more than one affected column; the log line names 
them,
+        // so all of them must come back, and unaffected siblings must not.
+        assert_eq!(
+            reinterpreted(
+                vec![
+                    Field::new("a", ts(TimeUnit::Microsecond, Some("UTC")), 
true),
+                    Field::new("ok", DataType::Int32, true),
+                    Field::new("b", ts(TimeUnit::Microsecond, Some("UTC")), 
true),
+                ],
+                vec![
+                    Field::new("a", ts(TimeUnit::Millisecond, Some("UTC")), 
true),
+                    Field::new("ok", DataType::Int64, true),
+                    Field::new("b", ts(TimeUnit::Millisecond, Some("UTC")), 
true),
+                ],
+            ),
+            vec!["a".to_string(), "b".to_string()]
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_ignores_value_preserving_evolutions() {
+        // Each of these evolves the column but PRESERVES what the value 
denotes,
+        // so pushdown stays sound and must not be declined.
+        let cases: Vec<(&str, DataType, DataType)> = vec![
+            // NTZ micros→millis is an arithmetic ÷1000, not a relabel: same 
instant.
+            (
+                "ntz_micros_to_millis",
+                ts(TimeUnit::Microsecond, None),
+                ts(TimeUnit::Millisecond, None),
+            ),
+            // The reverse direction is a legitimate widening via arrow_cast 
(×1000).
+            (
+                "millis_to_micros",
+                ts(TimeUnit::Millisecond, Some("UTC")),
+                ts(TimeUnit::Microsecond, Some("UTC")),
+            ),
+            // Same unit on both sides: no evolution at all.
+            (
+                "micros_to_micros",
+                ts(TimeUnit::Microsecond, Some("UTC")),
+                ts(TimeUnit::Microsecond, Some("UTC")),
+            ),
+            // Ordinary promotions.
+            ("int_widening", DataType::Int32, DataType::Int64),
+            ("float_widening", DataType::Float32, DataType::Float64),
+            // Mixed tz-awareness is NOT the #18132 shape (Java matches only 
the
+            // tz-aware logical classes), so it must not be flagged either way.
+            (
+                "ntz_file_to_tz_table",
+                ts(TimeUnit::Microsecond, None),
+                ts(TimeUnit::Millisecond, Some("UTC")),
+            ),
+            (
+                "tz_file_to_ntz_table",
+                ts(TimeUnit::Microsecond, Some("UTC")),
+                ts(TimeUnit::Millisecond, None),
+            ),
+            // Seconds and nanos are outside the repair entirely.
+            (
+                "seconds_to_millis",
+                ts(TimeUnit::Second, Some("UTC")),
+                ts(TimeUnit::Millisecond, Some("UTC")),
+            ),
+            (
+                "micros_to_nanos",
+                ts(TimeUnit::Microsecond, Some("UTC")),
+                ts(TimeUnit::Nanosecond, Some("UTC")),
+            ),
+        ];
+        for (name, file, required) in cases {
+            assert!(
+                reinterpreted(
+                    vec![Field::new("c", file, true)],
+                    vec![Field::new("c", required, true)],
+                )
+                .is_empty(),
+                "{name} preserves the value's meaning and must keep its 
pushdown"
+            );
+        }
+    }
+
+    #[test]
+    fn reinterpreted_columns_ignores_a_column_absent_from_the_table_schema() {
+        // A file column the table does not ask for is never projected, so it 
is
+        // never repaired and cannot be misread.
+        assert!(
+            reinterpreted(
+                vec![Field::new(
+                    "ts",
+                    ts(TimeUnit::Microsecond, Some("UTC")),
+                    true
+                )],
+                vec![Field::new("other", DataType::Int32, true)],
+            )
+            .is_empty()
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_matches_names_case_insensitively() {
+        // The projection resolves names case-insensitively (index_of_ci), so 
this
+        // must too, or a file spelling the column `TS` would push unsafely.
+        assert_eq!(
+            reinterpreted(
+                vec![Field::new(
+                    "TS",
+                    ts(TimeUnit::Microsecond, Some("UTC")),
+                    true
+                )],
+                vec![Field::new(
+                    "ts",
+                    ts(TimeUnit::Millisecond, Some("UTC")),
+                    true
+                )],
+            ),
+            vec!["TS".to_string()],
+            "the returned name is the FILE's spelling, which is how a 
predicate \
+             addresses the parquet column"
+        );
+    }
+
+    #[test]
+    fn reinterpreted_columns_sees_through_containers() {
+        // The repair recurses into structs/lists/maps, so the rule must too --
+        // otherwise an affected field nested one level down keeps its 
pushdown and
+        // drops rows silently.
+        let nested = |unit: TimeUnit| {
+            DataType::Struct(vec![Field::new("inner", ts(unit, Some("UTC")), 
true)].into())
+        };
+        assert_eq!(
+            reinterpreted(
+                vec![Field::new("s", nested(TimeUnit::Microsecond), true)],
+                vec![Field::new("s", nested(TimeUnit::Millisecond), true)],
+            ),
+            vec!["s".to_string()],
+            "an affected field inside a struct must flag its top-level column"
+        );
+
+        let listed = |unit: TimeUnit| {
+            DataType::List(Arc::new(Field::new("item", ts(unit, Some("UTC")), 
true)))
+        };
+        assert_eq!(
+            reinterpreted(
+                vec![Field::new("l", listed(TimeUnit::Microsecond), true)],
+                vec![Field::new("l", listed(TimeUnit::Millisecond), true)],
+            ),
+            vec!["l".to_string()],
+            "and so must one inside a list"
+        );
+    }
 }
diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs
index a025878f..4dcfb0e4 100644
--- a/crates/core/src/storage/mod.rs
+++ b/crates/core/src/storage/mod.rs
@@ -136,17 +136,35 @@ pub struct ReadVolume {
     /// 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.
+    /// Times a selector WAS installed by the caller but a gate refused to 
pass it
+    /// down — either the merge-safety gate or a value-reinterpreting 
logical-type
+    /// repair on the file.
     ///
-    /// 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:
+    /// Without this a gate silently defeats the counter above: a suppressed
+    /// selector is a third state that also reads zero calls.
+    ///
+    /// Counted regardless of cause, so it stays a faithful answer to "was one
+    /// installed but not passed down"; [`Self::pushdown_suppressed_by_repair`]
+    /// says which cause. Read them 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  a gate refused it; by_repair == 0 means 
the
+    ///                               merge-safety gate (the read merges, and 
the
+    ///                               predicate is not primary-key-safe),
+    ///                               by_repair > 0 means a repair conflict
     ///   calls == 0, suppressed == 0 no caller ever installed one
     pub row_group_selector_suppressed: AtomicU64,
+    /// Base files where a pushed predicate read a column THIS file mislabels, 
so
+    /// every pushdown mechanism was withdrawn for it.
+    ///
+    /// Counted once per such file whether or not a selector was installed, so
+    /// unlike [`Self::row_group_selector_suppressed`] it also covers the
+    /// row-filter side.
+    ///
+    /// Non-zero is expected on a table with legacy `parquet-mr` base files 
and a
+    /// predicate over a tz-aware millis column: those files fell back to the
+    /// post-scan filter, they did not lose rows. Rising on a table that 
should be
+    /// all-micros is the signal worth chasing.
+    pub pushdown_suppressed_by_repair: AtomicU64,
     /// Rows the file contains, from parquet metadata.
     pub file_rows: AtomicU64,
     /// Rows the stream actually yielded, after any row filter. `file_rows -
@@ -185,6 +203,15 @@ impl ReadVolume {
             .fetch_add(1, Ordering::Relaxed);
     }
 
+    /// This base file mislabels a column the pushed predicate reads, so every
+    /// pushdown mechanism was withdrawn for it. Counted separately from
+    /// `record_selector_suppressed` because it fires with no selector 
installed
+    /// too, and because the two causes must stay distinguishable.
+    pub(crate) fn record_pushdown_suppressed_by_repair(&self) {
+        self.pushdown_suppressed_by_repair
+            .fetch_add(1, Ordering::Relaxed);
+    }
+
     pub(crate) fn add_rows_out(&self, n: u64) {
         self.rows_out.fetch_add(n, Ordering::Relaxed);
     }

Reply via email to