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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new d66d3223 fix(predicate): resolve _ROW_ID by name, not by its 
placeholder leaf index (#642)
d66d3223 is described below

commit d66d3223159cd72bead6c36cc2531fec8ab35f7b
Author: Jiajia Li <[email protected]>
AuthorDate: Sun Aug 9 21:55:57 2026 +0800

    fix(predicate): resolve _ROW_ID by name, not by its placeholder leaf index 
(#642)
---
 crates/paimon/src/arrow/filtering.rs             |  49 ++-
 crates/paimon/src/arrow/format/blob.rs           |   7 +-
 crates/paimon/src/arrow/format/mosaic.rs         |  27 ++
 crates/paimon/src/arrow/format/orc.rs            |   7 +-
 crates/paimon/src/arrow/format/parquet.rs        |  54 +++-
 crates/paimon/src/arrow/format/vortex.rs         |   5 +
 crates/paimon/src/arrow/residual.rs              | 152 ++++++---
 crates/paimon/src/predicate_stats.rs             |   6 +-
 crates/paimon/src/spec/mod.rs                    |   2 +
 crates/paimon/src/spec/predicate.rs              |  85 ++++-
 crates/paimon/src/spec/schema.rs                 |  22 ++
 crates/paimon/src/table/data_evolution_reader.rs | 237 +++++++++++++-
 crates/paimon/src/table/format_table_read.rs     |   4 +
 crates/paimon/src/table/kv_file_reader.rs        |  77 +++++
 crates/paimon/src/table/read_builder.rs          | 392 +++++++++++++++++++++--
 crates/paimon/src/table/row_id_predicate.rs      | 182 +++++++----
 crates/paimon/src/table/stats_filter.rs          |   8 +-
 crates/paimon/src/table/table_scan.rs            |  60 +++-
 crates/paimon/src/table/vector_search_builder.rs |  17 +-
 19 files changed, 1213 insertions(+), 180 deletions(-)

diff --git a/crates/paimon/src/arrow/filtering.rs 
b/crates/paimon/src/arrow/filtering.rs
index 16170317..b502e7f7 100644
--- a/crates/paimon/src/arrow/filtering.rs
+++ b/crates/paimon/src/arrow/filtering.rs
@@ -17,7 +17,7 @@
 
 use crate::arrow::schema_evolution::create_index_mapping;
 pub(crate) use crate::predicate_stats::{predicates_may_match_with_schema, 
StatsAccessor};
-use crate::spec::{DataField, Predicate, PredicateOperator};
+use crate::spec::{is_row_id_column, DataField, Predicate, PredicateOperator};
 
 /// Remap predicates from table-level indices to file-level indices.
 /// Predicates referencing fields not present in the file are resolved based on
@@ -44,6 +44,12 @@ fn remap_predicate(predicate: &Predicate, mapping: 
&[Option<usize>]) -> Predicat
             op,
             literals,
         } => {
+            // `_ROW_ID` is not a file column and has no per-file position, so
+            // mapping its placeholder index would collapse the leaf to a
+            // constant. Keep it; the residual resolves it by name.
+            if is_row_id_column(column) {
+                return predicate.clone();
+            }
             match mapping.get(*index).copied().flatten() {
                 Some(file_index) => Predicate::Leaf {
                     column: column.clone(),
@@ -134,3 +140,44 @@ fn normalize_field_mapping(mapping: Option<Vec<i32>>, 
num_fields: usize) -> Vec<
         })
         .unwrap_or_else(|| identity_field_mapping(num_fields))
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{DataType, Datum, IntType, PredicateBuilder, 
PredicateOperator};
+
+    #[test]
+    fn test_a_row_id_leaf_survives_per_file_remapping() {
+        let table_fields = vec![
+            DataField::new(1, "added".to_string(), 
DataType::Int(IntType::new())),
+            DataField::new(0, "base".to_string(), 
DataType::Int(IntType::new())),
+        ];
+        let file_fields = vec![table_fields[1].clone()];
+        let leaf = crate::spec::row_id_leaf(PredicateOperator::NotEq, 
vec![Datum::Long(102)]);
+
+        assert_eq!(
+            remap_predicates_to_file(std::slice::from_ref(&leaf), 
&table_fields, &file_fields),
+            vec![leaf]
+        );
+    }
+
+    #[test]
+    fn test_a_row_id_branch_can_die_during_per_file_remapping() {
+        let table_fields = vec![
+            DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+            DataField::new(1, "added".to_string(), 
DataType::Int(IntType::new())),
+        ];
+        let file_fields = vec![table_fields[0].clone()];
+        let filter = Predicate::or(vec![
+            PredicateBuilder::new(&table_fields)
+                .is_null("added")
+                .unwrap(),
+            crate::spec::row_id_leaf(PredicateOperator::Eq, 
vec![Datum::Long(5)]),
+        ]);
+
+        assert_eq!(
+            remap_predicates_to_file(&[filter], &table_fields, &file_fields),
+            vec![Predicate::AlwaysTrue]
+        );
+    }
+}
diff --git a/crates/paimon/src/arrow/format/blob.rs 
b/crates/paimon/src/arrow/format/blob.rs
index 080c5056..a7c7d7ed 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -135,10 +135,15 @@ impl FormatFileReader for BlobFormatReader {
         reader: Box<dyn FileRead>,
         file_size: u64,
         read_fields: &[DataField],
-        _predicates: Option<&FilePredicates>,
+        predicates: Option<&FilePredicates>,
         batch_size: Option<usize>,
         row_selection: Option<Vec<RowRange>>,
     ) -> crate::Result<ArrowRecordBatchStream> {
+        // This reader evaluates no predicate at all, so nothing would enforce 
a
+        // `_ROW_ID` one.
+        if let Some(fp) = predicates {
+            
crate::table::row_id_predicate::reject_row_id_filter(&fp.predicates, "blob 
files")?;
+        }
         let field_kind = validate_read_fields(read_fields)?;
 
         let target_schema = build_target_arrow_schema(read_fields)?;
diff --git a/crates/paimon/src/arrow/format/mosaic.rs 
b/crates/paimon/src/arrow/format/mosaic.rs
index 356fcc49..304eadc9 100644
--- a/crates/paimon/src/arrow/format/mosaic.rs
+++ b/crates/paimon/src/arrow/format/mosaic.rs
@@ -56,6 +56,11 @@ impl FormatFileReader for MosaicFormatReader {
         batch_size: Option<usize>,
         row_selection: Option<Vec<RowRange>>,
     ) -> crate::Result<ArrowRecordBatchStream> {
+        // This reader only prunes row groups by stats, and stats cannot 
decide a
+        // `_ROW_ID` predicate, so nothing would enforce it.
+        if let Some(fp) = predicates {
+            
crate::table::row_id_predicate::reject_row_id_filter(&fp.predicates, "mosaic 
files")?;
+        }
         let handle = tokio::runtime::Handle::try_current().map_err(|e| 
Error::UnexpectedError {
             message: "Mosaic reader requires a Tokio runtime".to_string(),
             source: Some(Box::new(e)),
@@ -976,6 +981,28 @@ mod tests {
         assert_eq!(ids.value(4), 5);
     }
 
+    #[tokio::test]
+    async fn test_row_id_predicate_is_rejected() {
+        let data = write_mosaic(&sample_batch());
+        let fields = data_fields();
+        let predicates = FilePredicates {
+            predicates: vec![crate::spec::row_id_leaf(
+                crate::spec::PredicateOperator::NotEq,
+                vec![Datum::Long(101)],
+            )],
+            row_filter_factory: None,
+            file_fields: fields.clone(),
+        };
+        let err = read_batches_with_predicates(data, &fields, 
Some(&predicates), None)
+            .await
+            .unwrap_err();
+
+        assert!(
+            matches!(&err, Error::Unsupported { message } if 
message.contains("_ROW_ID")),
+            "unexpected error: {err:?}"
+        );
+    }
+
     #[tokio::test]
     async fn test_read_projection_order() {
         let fields = data_fields();
diff --git a/crates/paimon/src/arrow/format/orc.rs 
b/crates/paimon/src/arrow/format/orc.rs
index 8c600b0d..6ebbeb5f 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -17,7 +17,7 @@
 
 use super::{FilePredicates, FormatFileReader};
 use crate::io::FileRead;
-use crate::spec::{DataField, DataType, Datum, Predicate, PredicateOperator};
+use crate::spec::{is_row_id_column, DataField, DataType, Datum, Predicate, 
PredicateOperator};
 use crate::table::{ArrowRecordBatchStream, RowRange};
 use crate::Error;
 use async_trait::async_trait;
@@ -222,6 +222,7 @@ fn build_orc_leaf_predicate(
     file_fields: &[DataField],
 ) -> Option<orc_rust::predicate::Predicate> {
     let Predicate::Leaf {
+        column,
         index,
         op,
         literals,
@@ -230,6 +231,10 @@ fn build_orc_leaf_predicate(
     else {
         return None;
     };
+    // Not in the file, and its index would push the wrong column down.
+    if is_row_id_column(column) {
+        return None;
+    }
     let file_field = file_fields.get(*index)?;
     let column = file_field.name();
 
diff --git a/crates/paimon/src/arrow/format/parquet.rs 
b/crates/paimon/src/arrow/format/parquet.rs
index 58491401..c039074d 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -25,8 +25,8 @@ use crate::arrow::{ParquetReadBudget, RowFilter, 
RowFilterContext};
 use crate::io::{FileRead, OutputFile};
 use crate::spec::stats::BinaryTableStats;
 use crate::spec::{
-    BinaryRowBuilder, CoreOptions, DataField, DataType, Datum, 
MetadataStatsMode, Predicate,
-    PredicateOperator,
+    is_row_id_column, BinaryRowBuilder, CoreOptions, DataField, DataType, 
Datum, MetadataStatsMode,
+    Predicate, PredicateOperator,
 };
 use crate::table::{ArrowRecordBatchStream, RowRange};
 use crate::Error;
@@ -767,11 +767,11 @@ fn build_parquet_arrow_predicate(
     // the union of referenced Parquet roots, ordered exactly as the projected
     // RecordBatch. This preserves OR/NOT semantics; splitting it into leaf
     // RowFilters would incorrectly turn the expression into a conjunction.
-    let mut field_indices = Vec::new();
-    crate::arrow::residual::collect_predicate_field_indices(predicate, &mut 
field_indices);
-    let mut projected = field_indices
+    let mut leaf_refs = Vec::new();
+    crate::arrow::residual::collect_predicate_leaf_refs(predicate, &mut 
leaf_refs);
+    let mut projected = leaf_refs
         .into_iter()
-        .filter_map(|index| {
+        .filter_map(|(_, index)| {
             let field = file_fields.get(index)?;
             parquet_root_index(parquet_schema, field.name()).map(|root| (root, 
field.clone()))
         })
@@ -826,11 +826,19 @@ fn parquet_predicate_row_filter_accepted(
     match predicate {
         Predicate::AlwaysTrue | Predicate::AlwaysFalse => Ok(true),
         Predicate::Leaf {
+            column,
             index,
             op,
             literals,
             ..
-        } => parquet_leaf_row_filter_accepted(parquet_schema, *index, *op, 
literals, file_fields),
+        } => parquet_leaf_row_filter_accepted(
+            parquet_schema,
+            column,
+            *index,
+            *op,
+            literals,
+            file_fields,
+        ),
         Predicate::And(children) | Predicate::Or(children) => {
             for child in children {
                 if !parquet_predicate_row_filter_accepted(parquet_schema, 
child, file_fields)? {
@@ -853,6 +861,7 @@ fn parquet_predicate_row_filter_accepted(
 /// an unsupported (but well-formed) leaf yields `Ok(false)`.
 fn parquet_leaf_row_filter_accepted(
     parquet_schema: &parquet::schema::types::SchemaDescriptor,
+    column: &str,
     index: usize,
     op: PredicateOperator,
     literals: &[Datum],
@@ -861,6 +870,11 @@ fn parquet_leaf_row_filter_accepted(
     if !predicate_supported_for_parquet_row_filter(op) {
         return Ok(false);
     }
+    // Not in the file, so the decoder cannot evaluate it. Rejecting the leaf
+    // rejects any enclosing predicate too, leaving it to the post-scan 
residual.
+    if is_row_id_column(column) {
+        return Ok(false);
+    }
     let Some(file_field) = file_fields.get(index) else {
         return Ok(false);
     };
@@ -2243,6 +2257,32 @@ mod tests {
         assert!(row_filter.is_some());
     }
 
+    #[test]
+    fn test_row_id_predicate_builds_no_decoder_row_filter() {
+        let fields = test_fields();
+        let schema = test_parquet_schema();
+        assert!(build_parquet_row_filter(
+            &schema,
+            &[crate::spec::row_id_leaf(
+                super::PredicateOperator::Eq,
+                vec![Datum::Long(1)]
+            )],
+            &fields
+        )
+        .expect("row filter should build")
+        .is_none());
+
+        let mixed = Predicate::or(vec![
+            crate::spec::row_id_leaf(super::PredicateOperator::Eq, 
vec![Datum::Long(1)]),
+            PredicateBuilder::new(&fields)
+                .equal("score", Datum::Int(7))
+                .expect("leaf should build"),
+        ]);
+        assert!(build_parquet_row_filter(&schema, &[mixed], &fields)
+            .expect("row filter should build")
+            .is_none());
+    }
+
     // -----------------------------------------------------------------------
     // String predicate tests (StartsWith / EndsWith / Contains)
     // -----------------------------------------------------------------------
diff --git a/crates/paimon/src/arrow/format/vortex.rs 
b/crates/paimon/src/arrow/format/vortex.rs
index 163e510a..7d6d637e 100644
--- a/crates/paimon/src/arrow/format/vortex.rs
+++ b/crates/paimon/src/arrow/format/vortex.rs
@@ -149,6 +149,11 @@ fn read_vortex_batches(
             })?;
 
     if scan_fields.is_empty() {
+        // `_ROW_ID` never widens the scan, so this fast path has nothing to
+        // evaluate it against and would treat the predicate as matching.
+        if let Some(fp) = predicates.as_ref() {
+            
crate::table::row_id_predicate::reject_row_id_filter(&fp.predicates, "this 
read")?;
+        }
         let row_count = if constant_predicates_match(predicates.as_ref()) {
             match &row_selection {
                 Some(ranges) => ranges.iter().map(|r| r.count() as 
usize).sum(),
diff --git a/crates/paimon/src/arrow/residual.rs 
b/crates/paimon/src/arrow/residual.rs
index fe4e9c43..93159af6 100644
--- a/crates/paimon/src/arrow/residual.rs
+++ b/crates/paimon/src/arrow/residual.rs
@@ -45,7 +45,7 @@
 //! must not reference any vortex-specific types.
 
 use crate::arrow::format::FilePredicates;
-use crate::spec::{DataField, DataType, Datum, Predicate, PredicateOperator};
+use crate::spec::{is_row_id_column, DataField, DataType, Datum, Predicate, 
PredicateOperator};
 use crate::Error;
 use arrow_array::{
     Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Datum as 
ArrowDatum, Decimal128Array,
@@ -159,41 +159,51 @@ fn evaluate_predicate_mask(
             })))
         }
         Predicate::Leaf {
+            column,
             index,
             op,
             literals,
             data_type: predicate_data_type,
-            ..
         } => {
-            let Some(file_field) = file_fields.get(*index) else {
-                return Ok(None);
+            // Resolve the batch column by NAME, but pick that name carefully: 
a
+            // real column may be renamed in the file, so its batch name comes
+            // from `file_fields[index]`; `_ROW_ID` is absent from 
`file_fields`
+            // and only its own name is meaningful. Java's `PredicateRemapper`
+            // rebinds by name too.
+            let file_field = if is_row_id_column(column) {
+                None
+            } else {
+                match file_fields.get(*index) {
+                    Some(field) => Some(field),
+                    None => return Ok(None),
+                }
             };
-            // Resolve the predicate column in the batch by NAME against the 
batch's
-            // own schema. We must not index by the column's position in
-            // `scan_fields`: a reader's emitted batch may order columns by 
its file
-            // schema (e.g. ORC `ProjectionMask::named_roots`), not by 
`scan_fields`
-            // order, so positional indexing can select the wrong column (and
-            // compare mismatched types). `scan_fields` is used only to detect 
the
-            // Gap-A "predicate column not scanned" bug below.
-            let column = batch
+            let field_name = file_field.map_or(column.as_str(), 
DataField::name);
+            let field_type = file_field.map_or(predicate_data_type, 
DataField::data_type);
+            // Never index by position in `scan_fields`: a reader's emitted 
batch
+            // may order columns by its file schema (e.g. ORC
+            // `ProjectionMask::named_roots`) rather than by `scan_fields` 
order.
+            // `scan_fields` is used only for the Gap-A guard below.
+            let array = batch
                 .schema()
-                .index_of(file_field.name())
+                .index_of(field_name)
                 .ok()
                 .map(|batch_index| batch.column(batch_index));
-            let Some(column) = column else {
-                // The predicate column exists in the file schema but is absent
-                // from the batch actually scanned — this is the Gap-A bug (a
-                // reader that did not widen its scan to include predicate 
columns
-                // before filtering). It must never happen. Fail loudly in
-                // debug/test builds; degrade to a skip (rather than panic) in
-                // release. `scan_fields` is unused for resolution now (we 
look up
-                // by name in the batch), so touch it here only to keep the 
guard
-                // message informative.
+            let Some(array) = array else {
                 let _ = scan_fields;
+                if file_field.is_none() {
+                    // Backstop for residuals applied to a predicate-free 
reader
+                    // (PK merge output, vector search); readers that own their
+                    // predicates reject earlier.
+                    return 
Err(crate::table::row_id_predicate::unsupported_row_id_filter(
+                        "this read",
+                    ));
+                }
+                // A real column missing here is a reader bug: it did not widen
+                // its scan to the predicate columns.
                 debug_assert!(
                     false,
-                    "residual predicate column '{}' exists in file_fields but 
is missing from the scanned batch; the reader must widen its scan to include 
predicate columns",
-                    file_field.name()
+                    "residual predicate column '{field_name}' is missing from 
the scanned batch; the reader must widen its scan to include predicate columns"
                 );
                 return Ok(None);
             };
@@ -206,16 +216,14 @@ fn evaluate_predicate_mask(
             // column up to the predicate type first — then the literal is 
always
             // representable and the comparison is exact.
             let predicate_arrow_type = 
crate::arrow::paimon_type_to_arrow(predicate_data_type)?;
-            let mask = if column.data_type() == &predicate_arrow_type {
-                evaluate_exact_leaf_predicate(column, file_field.data_type(), 
*op, literals)
+            let mask = if array.data_type() == &predicate_arrow_type {
+                evaluate_exact_leaf_predicate(array, field_type, *op, literals)
             } else {
-                let cast_column = arrow_cast::cast(column, 
&predicate_arrow_type).map_err(|e| {
+                let cast_column = arrow_cast::cast(array, 
&predicate_arrow_type).map_err(|e| {
                     Error::DataInvalid {
                         message: format!(
-                            "Failed to cast residual column '{}' from {:?} to 
{:?}: {e}",
-                            file_field.name(),
-                            column.data_type(),
-                            predicate_arrow_type
+                            "Failed to cast residual column '{field_name}' 
from {:?} to {predicate_arrow_type:?}: {e}",
+                            array.data_type()
                         ),
                         source: Some(Box::new(e)),
                     }
@@ -251,11 +259,15 @@ pub(crate) fn widen_scan_fields(
     let mut fields = read_fields.to_vec();
 
     if let Some(fp) = predicates {
-        let mut predicate_indices = Vec::new();
+        let mut refs = Vec::new();
         for predicate in &fp.predicates {
-            collect_predicate_field_indices(predicate, &mut predicate_indices);
+            collect_predicate_leaf_refs(predicate, &mut refs);
         }
-        for index in predicate_indices {
+        for (name, index) in refs {
+            // Not read from the file, so there is nothing to widen with.
+            if is_row_id_column(name) {
+                continue;
+            }
             if let Some(field) = fp.file_fields.get(index) {
                 push_unique_scan_field(&mut fields, field);
             }
@@ -265,15 +277,22 @@ pub(crate) fn widen_scan_fields(
     fields
 }
 
-pub(crate) fn collect_predicate_field_indices(predicate: &Predicate, indices: 
&mut Vec<usize>) {
+/// Collect every leaf as `(column name, leaf index)`.
+///
+/// Callers that resolve a leaf positionally must check the name first — see
+/// [`crate::spec::is_row_id_column`].
+pub(crate) fn collect_predicate_leaf_refs<'a>(
+    predicate: &'a Predicate,
+    refs: &mut Vec<(&'a str, usize)>,
+) {
     match predicate {
-        Predicate::Leaf { index, .. } => indices.push(*index),
+        Predicate::Leaf { column, index, .. } => refs.push((column.as_str(), 
*index)),
         Predicate::And(children) | Predicate::Or(children) => {
             for child in children {
-                collect_predicate_field_indices(child, indices);
+                collect_predicate_leaf_refs(child, refs);
             }
         }
-        Predicate::Not(inner) => collect_predicate_field_indices(inner, 
indices),
+        Predicate::Not(inner) => collect_predicate_leaf_refs(inner, refs),
         Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
     }
 }
@@ -972,7 +991,7 @@ fn float64_literal(literal: &Datum) -> Option<f64> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::spec::{IntType, VarCharType};
+    use crate::spec::{row_id_leaf, IntType, VarCharType, ROW_ID_FIELD_NAME};
     use arrow_array::{Int32Array, StringArray};
     use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
     use std::sync::Arc;
@@ -1454,6 +1473,61 @@ mod tests {
         let _ = filter_record_batch_by_predicates(batch, &fp, &scan_fields);
     }
 
+    fn batch_with_row_id(values: Vec<i32>, row_ids: Vec<i64>) -> RecordBatch {
+        let schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("v", ArrowDataType::Int32, true),
+            ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+        ]));
+        RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(Int32Array::from(values)),
+                Arc::new(arrow_array::Int64Array::from(row_ids)),
+            ],
+        )
+        .unwrap()
+    }
+
+    #[test]
+    fn test_row_id_leaf_resolves_by_name_not_by_placeholder_index() {
+        let v = int_field(1, "v");
+        let batch = batch_with_row_id(vec![99, 7, 7], vec![1, 2, 3]);
+        let pred = row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(1)]);
+        let fp = file_predicates(vec![pred], vec![v.clone()]);
+        let out = filter_record_batch_by_predicates(batch, &fp, &[v]).unwrap();
+        assert_eq!(int_values(&out), vec![99]);
+    }
+
+    #[test]
+    fn test_row_id_leaf_inside_a_disjunction_resolves_by_name() {
+        let v = int_field(1, "v");
+        let batch = batch_with_row_id(vec![99, 7, 5], vec![1, 2, 3]);
+        let pred = Predicate::or(vec![
+            row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(1)]),
+            leaf(
+                0,
+                DataType::Int(IntType::new()),
+                PredicateOperator::Eq,
+                vec![Datum::Int(7)],
+            ),
+        ]);
+        let fp = file_predicates(vec![pred], vec![v.clone()]);
+        let out = filter_record_batch_by_predicates(batch, &fp, &[v]).unwrap();
+        assert_eq!(int_values(&out), vec![99, 7]);
+    }
+
+    #[test]
+    fn test_widen_scan_fields_skips_system_columns() {
+        let other = int_field(1, "other");
+        let v = int_field(2, "v");
+        let fp = file_predicates(
+            vec![row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(1)])],
+            vec![other, v.clone()],
+        );
+        let widened = widen_scan_fields(std::slice::from_ref(&v), Some(&fp));
+        assert_eq!(widened, vec![v]);
+    }
+
     #[test]
     fn test_filter_when_batch_column_order_differs_from_scan_fields() {
         // Regression: a reader (e.g. ORC `ProjectionMask::named_roots`) may 
emit
diff --git a/crates/paimon/src/predicate_stats.rs 
b/crates/paimon/src/predicate_stats.rs
index 71c930dc..57d01e76 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::spec::{DataField, DataType, Datum, Predicate, PredicateOperator};
+use crate::spec::{is_row_id_column, DataField, DataType, Datum, Predicate, 
PredicateOperator};
 use std::cmp::Ordering;
 
 pub(crate) trait StatsAccessor {
@@ -426,6 +426,8 @@ fn predicate_may_match_with_schema<T: StatsAccessor>(
         Predicate::Not(inner) => {
             !predicate_must_match_with_schema(inner, stats, field_mapping, 
file_fields)
         }
+        // `_ROW_ID` has no column stats, so never prune on it.
+        Predicate::Leaf { column, .. } if is_row_id_column(column) => true,
         Predicate::Leaf {
             index,
             data_type,
@@ -469,6 +471,8 @@ fn predicate_must_match_with_schema<T: StatsAccessor>(
         Predicate::Not(inner) => {
             !predicate_may_match_with_schema(inner, stats, field_mapping, 
file_fields)
         }
+        // Stats cannot decide `_ROW_ID`, so it never provably matches.
+        Predicate::Leaf { column, .. } if is_row_id_column(column) => false,
         Predicate::Leaf {
             index,
             data_type,
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 22780b91..3fb3802e 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -101,6 +101,8 @@ pub(crate) use predicate::datum_cmp;
 pub(crate) use predicate::eval_row;
 pub(crate) use predicate::extract_datum;
 pub(crate) use predicate::like_match;
+#[cfg(test)]
+pub(crate) use predicate::row_id_leaf;
 pub use predicate::{
     field_idx_to_partition_idx, Datum, Predicate, PredicateBuilder, 
PredicateOperator, Transform,
     TransformInput,
diff --git a/crates/paimon/src/spec/predicate.rs 
b/crates/paimon/src/spec/predicate.rs
index a6d609a6..66946abd 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -26,7 +26,7 @@
 use crate::error::*;
 use crate::spec::binary_row::BinaryRow;
 use crate::spec::types::DataType;
-use crate::spec::DataField;
+use crate::spec::{is_row_id_column, DataField};
 use std::cmp::Ordering;
 use std::fmt;
 
@@ -434,6 +434,9 @@ impl Predicate {
                 op,
                 literals,
             } => {
+                if is_row_id_column(column) {
+                    return None;
+                }
                 let new_index = (*mapping.get(*index)?)?;
                 Some(Predicate::Leaf {
                     column: column.clone(),
@@ -472,7 +475,9 @@ impl Predicate {
     /// retained as a residual data predicate after partition projection.
     pub(crate) fn references_only_mapped_fields(&self, mapping: 
&[Option<usize>]) -> bool {
         match self {
-            Predicate::Leaf { index, .. } => 
mapping.get(*index).is_some_and(Option::is_some),
+            Predicate::Leaf { column, index, .. } => {
+                !is_row_id_column(column) && 
mapping.get(*index).is_some_and(Option::is_some)
+            }
             Predicate::And(children) | Predicate::Or(children) => children
                 .iter()
                 .all(|child| child.references_only_mapped_fields(mapping)),
@@ -483,6 +488,8 @@ impl Predicate {
 
     /// Project leaf field indices from table schema space into a smaller 
field space.
     ///
+    /// A `_ROW_ID` leaf never maps — see [`is_row_id_column`].
+    ///
     /// Unlike [`Self::remap_field_index`], mixed `AND` subtrees keep the 
children
     /// that can be projected and drop the rest. `OR` and `NOT` still require 
all
     /// children to be projectable to preserve correctness.
@@ -501,6 +508,9 @@ impl Predicate {
                 op,
                 literals,
             } => {
+                if is_row_id_column(column) {
+                    return None;
+                }
                 let new_index = (*mapping.get(*index)?)?;
                 Some(Predicate::Leaf {
                     column: column.clone(),
@@ -1344,6 +1354,19 @@ fn validate_datum_matches_type(datum: &Datum, data_type: 
&DataType) -> Result<()
     Ok(())
 }
 
+/// Build a `_ROW_ID` leaf the way callers have to: the column is in no 
schema, so
+/// [`PredicateBuilder`] cannot resolve it and the index is a placeholder.
+#[cfg(test)]
+pub(crate) fn row_id_leaf(op: PredicateOperator, literals: Vec<Datum>) -> 
Predicate {
+    Predicate::Leaf {
+        column: crate::spec::ROW_ID_FIELD_NAME.to_string(),
+        index: 0,
+        data_type: DataType::BigInt(crate::spec::BigIntType::new()),
+        op,
+        literals,
+    }
+}
+
 // ---------------------------------------------------------------------------
 // field_idx_to_partition_idx
 // ---------------------------------------------------------------------------
@@ -1720,6 +1743,64 @@ mod tests {
         ]
     }
 
+    #[test]
+    fn test_system_column_never_maps_onto_a_field_space() {
+        let fields = test_fields();
+        let partition_first = [
+            fields[2].clone(),
+            fields[0].clone(),
+            fields[1].clone(),
+            fields[3].clone(),
+        ];
+        let mapping =
+            field_idx_to_partition_idx(&partition_first, &["dt".to_string(), 
"hr".to_string()]);
+        assert_eq!(mapping[0], Some(0), "field 0 is the dt partition key");
+
+        let leaf = row_id_leaf(PredicateOperator::GtEq, vec![Datum::Long(10)]);
+        assert_eq!(leaf.project_field_index_inclusive(&mapping), None);
+        assert_eq!(leaf.remap_field_index(&mapping), None);
+        assert!(!leaf.references_only_mapped_fields(&mapping));
+    }
+
+    #[test]
+    fn test_system_column_does_not_drag_a_conjunction_onto_a_field_space() {
+        let fields = test_fields();
+        let mapping = field_idx_to_partition_idx(&fields, &["hr".to_string()]);
+        let dt = PredicateBuilder::new(&fields)
+            .greater_or_equal("hr", Datum::Int(3))
+            .unwrap();
+
+        let conjunction = Predicate::and(vec![
+            row_id_leaf(PredicateOperator::GtEq, vec![Datum::Long(10)]),
+            dt.clone(),
+        ]);
+        let projected = 
conjunction.project_field_index_inclusive(&mapping).unwrap();
+        assert!(!matches!(projected, Predicate::And(_)), "only hr survives");
+        assert!(!conjunction.references_only_mapped_fields(&mapping));
+
+        let disjunction = Predicate::or(vec![
+            row_id_leaf(PredicateOperator::GtEq, vec![Datum::Long(10)]),
+            dt,
+        ]);
+        assert_eq!(disjunction.project_field_index_inclusive(&mapping), None);
+    }
+
+    #[test]
+    fn test_only_row_id_is_unresolvable_by_index() {
+        let fields = vec![
+            DataField::new(0, "rowkind".to_string(), 
DataType::Int(IntType::new())),
+            DataField::new(1, "hr".to_string(), DataType::Int(IntType::new())),
+        ];
+        let mapping = field_idx_to_partition_idx(&fields, 
&["rowkind".to_string()]);
+        let leaf = PredicateBuilder::new(&fields)
+            .equal("rowkind", Datum::Int(1))
+            .unwrap();
+
+        assert!(leaf.project_field_index_inclusive(&mapping).is_some());
+        assert!(leaf.remap_field_index(&mapping).is_some());
+        assert!(leaf.references_only_mapped_fields(&mapping));
+    }
+
     // ======================== PredicateBuilder basics 
========================
 
     #[test]
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 24a7b498..dd45712e 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -1015,6 +1015,28 @@ pub const VALUE_KIND_FIELD_ID: i32 = i32::MAX - 2;
 
 pub const ROW_KIND_FIELD_NAME: &str = "rowkind";
 
+/// A row's global id. Nullable: a data file lacking `first_row_id` yields 
nulls.
+pub(crate) fn row_id_data_field() -> DataField {
+    DataField::new(
+        ROW_ID_FIELD_ID,
+        ROW_ID_FIELD_NAME.to_string(),
+        DataType::BigInt(crate::spec::BigIntType::with_nullable(true)),
+    )
+}
+
+/// `_ROW_ID` is synthesized by the reader and is not a table column, so
+/// `PredicateBuilder` cannot resolve it and callers hand-build the leaf with a
+/// placeholder index. Every index-based resolution must recognize it by name
+/// instead, or it binds the predicate to whatever field sits at that index.
+///
+/// The other reserved names are excluded on purpose. `_SEQUENCE_NUMBER` and
+/// `_VALUE_KIND` are physical columns of a KV file and do have a position; 
none
+/// of them is ever referenced by a predicate. Widening this to every reserved
+/// name would instead break a schema that predates their rejection.
+pub(crate) fn is_row_id_column(name: &str) -> bool {
+    name == ROW_ID_FIELD_NAME
+}
+
 /// Must match Java Paimon's `SpecialFields.ROW_KIND` (Integer.MAX_VALUE - 4).
 pub const ROW_KIND_FIELD_ID: i32 = i32::MAX - 4;
 
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 2aa6d485..cf04829b 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -27,8 +27,7 @@ use crate::arrow::{build_target_arrow_schema, 
ParquetReadBudget};
 use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
 use crate::io::FileIO;
 use crate::spec::{
-    BigIntType, BlobDescriptor, BlobViewStruct, DataField, DataFileMeta, 
DataType, Predicate,
-    ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME,
+    BlobDescriptor, BlobViewStruct, DataField, DataFileMeta, DataType, 
Predicate, ROW_ID_FIELD_NAME,
 };
 use crate::table::dedicated_format_file_writer::is_blob_file_name;
 use crate::table::schema_manager::SchemaManager;
@@ -132,7 +131,7 @@ impl DataEvolutionReader {
         blob_view_resolve_enabled: bool,
         blob_view_rest_env: Option<RESTEnv>,
     ) -> crate::Result<Self> {
-        let row_id_index = read_type.iter().position(|f| f.name() == 
ROW_ID_FIELD_NAME);
+        let projected_row_id_index = read_type.iter().position(|f| f.name() == 
ROW_ID_FIELD_NAME);
         let file_read_type: Vec<DataField> = read_type
             .iter()
             .filter(|f| f.name() != ROW_ID_FIELD_NAME)
@@ -153,11 +152,25 @@ impl DataEvolutionReader {
         let wide_file_read_type =
             crate::arrow::residual::widen_scan_fields(&file_read_type, 
file_predicates.as_ref());
         // Wide batches at the _ROW_ID attach point: original read_type columns
-        // (caller order, _ROW_ID at row_id_index) followed by the extras.
-        // row_id_index <= file_read_type.len(), so inserting _ROW_ID never
-        // displaces a trailing extra column.
+        // (caller order, _ROW_ID at row_id_index) followed by the extras. A
+        // projected row_id_index is <= file_read_type.len(), so inserting
+        // _ROW_ID never displaces a trailing extra column.
         let mut wide_read_type = read_type;
         
wide_read_type.extend_from_slice(&wide_file_read_type[file_read_type.len()..]);
+        // A residual on `_ROW_ID` needs the column when `filter_wide_batch`
+        // runs, even unprojected. `widen_scan_fields` cannot supply a
+        // synthesized column, so append it and let `project_output` trim it.
+        let row_id_index = match projected_row_id_index {
+            Some(index) => Some(index),
+            None if predicates
+                .iter()
+                .any(super::row_id_predicate::references_row_id) =>
+            {
+                wide_read_type.push(crate::spec::row_id_data_field());
+                Some(wide_read_type.len() - 1)
+            }
+            None => None,
+        };
         let wide_output_schema = build_target_arrow_schema(&wide_read_type)?;
 
         Ok(Self {
@@ -413,8 +426,9 @@ impl DataEvolutionReader {
     ///
     /// Layout invariant: the first `output_schema.fields().len()` columns of
     /// `batch` are exactly the original read_type columns. Extras were 
appended
-    /// at the end by `widen_scan_fields`, and `_ROW_ID` insertion at
-    /// `row_id_index` keeps them trailing.
+    /// at the end — by `widen_scan_fields`, plus an unprojected `_ROW_ID` a
+    /// residual needs — and `_ROW_ID` insertion at `row_id_index` keeps them
+    /// trailing.
     fn project_output(&self, filtered: RecordBatch) -> 
crate::Result<RecordBatch> {
         let final_width = self.output_schema.fields().len();
         if filtered.num_columns() == final_width {
@@ -919,6 +933,11 @@ fn predicate_references_any_field(
 ) -> bool {
     match predicate {
         Predicate::Leaf { column, index, .. } => {
+            // Never a BLOB column; resolving its placeholder index would force
+            // every BLOB to be resolved before filtering.
+            if crate::spec::is_row_id_column(column) {
+                return false;
+            }
             field_names.contains(column)
                 || table_fields
                     .get(*index)
@@ -1083,7 +1102,7 @@ impl BlobViewLookup {
             let table = table.copy_with_options(options);
             let row_ranges = row_ranges_for_blob_view_refs(&refs);
             let mut read_builder = table.new_read_builder();
-            read_builder.with_read_type(vec![field.clone(), row_id_field()]);
+            read_builder.with_read_type(vec![field.clone(), 
crate::spec::row_id_data_field()]);
             read_builder.with_row_ranges(row_ranges);
             let plan = read_builder.new_scan().plan().await?;
             let read = read_builder.new_read()?;
@@ -1197,14 +1216,6 @@ fn row_ranges_for_blob_view_refs(refs: 
&[BlobViewStruct]) -> Vec<RowRange> {
     ranges
 }
 
-fn row_id_field() -> DataField {
-    DataField::new(
-        ROW_ID_FIELD_ID,
-        ROW_ID_FIELD_NAME.to_string(),
-        DataType::BigInt(BigIntType::with_nullable(true)),
-    )
-}
-
 #[allow(clippy::too_many_arguments)]
 fn open_source_stream(
     split: &DataSplit,
@@ -6988,6 +6999,198 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_evolution_read_applies_row_id_residual_to_the_row_ids() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+
+        let parquet_path = bucket_dir.join("data.parquet");
+        write_int_parquet_file(&parquet_path, vec![("id", vec![1, 2, 3, 4])], 
None);
+
+        let table = two_col_evolution_table(table_path);
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![data_file_meta_with_path(
+                "data.parquet",
+                100,
+                4,
+                1,
+                parquet_path.metadata().unwrap().len() as i64,
+                Some(vec!["id"]),
+            )])
+            .build()
+            .unwrap();
+
+        let predicate = crate::spec::row_id_leaf(
+            crate::spec::PredicateOperator::NotEq,
+            vec![Datum::Long(102)],
+        );
+
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&["id"]).unwrap();
+        builder.with_filter(predicate);
+        let read = builder.new_read().unwrap();
+        let batches = read
+            .to_arrow(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        assert_eq!(collect_int_values(&batches, "id"), vec![1, 2, 4]);
+        assert_eq!(batches[0].num_columns(), 1);
+    }
+
+    #[test]
+    fn test_row_id_filter_is_not_blob_dependent() {
+        let table_fields = vec![
+            DataField::new(0, "payload".to_string(), 
DataType::Blob(BlobType::new())),
+            DataField::new(1, "v".to_string(), DataType::Int(IntType::new())),
+        ];
+        let blob_fields: HashSet<String> = 
["payload".to_string()].into_iter().collect();
+        let row_id = crate::spec::row_id_leaf(
+            crate::spec::PredicateOperator::Between,
+            vec![Datum::Long(10), Datum::Long(20)],
+        );
+
+        assert!(!predicate_references_any_field(
+            &row_id,
+            &blob_fields,
+            &table_fields
+        ));
+        let on_blob = PredicateBuilder::new(&table_fields)
+            .is_null("payload")
+            .unwrap();
+        assert!(predicate_references_any_field(
+            &on_blob,
+            &blob_fields,
+            &table_fields
+        ));
+    }
+
+    #[tokio::test]
+    async fn 
test_evolution_read_enforces_a_row_id_leaf_nested_in_a_disjunction() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+        let parquet_path = bucket_dir.join("data.parquet");
+        write_int_parquet_file(&parquet_path, vec![("id", vec![1, 2, 3, 4])], 
None);
+
+        let table = two_col_evolution_table(table_path);
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![data_file_meta_with_path(
+                "data.parquet",
+                100,
+                4,
+                1,
+                parquet_path.metadata().unwrap().len() as i64,
+                Some(vec!["id"]),
+            )])
+            .build()
+            .unwrap();
+
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&["id"]).unwrap();
+        let rid = |op, v| crate::spec::row_id_leaf(op, vec![Datum::Long(v)]);
+        builder.with_filter(Predicate::and(vec![
+            rid(crate::spec::PredicateOperator::GtEq, 100),
+            Predicate::or(vec![
+                Predicate::and(vec![
+                    rid(crate::spec::PredicateOperator::Eq, 999),
+                    pb.equal("id", Datum::Int(2)).unwrap(),
+                ]),
+                pb.equal("id", Datum::Int(4)).unwrap(),
+            ]),
+        ]));
+        let batches = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        assert_eq!(collect_int_values(&batches, "id"), vec![4]);
+    }
+
+    #[tokio::test]
+    async fn test_row_id_filter_without_data_evolution_is_rejected() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+        let parquet_path = bucket_dir.join("data.parquet");
+        write_int_parquet_file(&parquet_path, vec![("id", vec![1, 2, 3, 4])], 
None);
+
+        let file_io = FileIOBuilder::new("file").build().unwrap();
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("value", DataType::Int(IntType::new()))
+                .build()
+                .unwrap(),
+        );
+        let table = Table::new(
+            file_io,
+            Identifier::new("default", "plain_t"),
+            table_path,
+            table_schema,
+            None,
+        );
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![data_file_meta_with_path(
+                "data.parquet",
+                100,
+                4,
+                1,
+                parquet_path.metadata().unwrap().len() as i64,
+                Some(vec!["id"]),
+            )])
+            .build()
+            .unwrap();
+
+        let predicate = crate::spec::row_id_leaf(
+            crate::spec::PredicateOperator::NotEq,
+            vec![Datum::Long(102)],
+        );
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&["id"]).unwrap();
+        builder.with_filter(predicate);
+        let err = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap_err();
+
+        assert!(
+            matches!(&err, crate::Error::Unsupported { message } if 
message.contains("_ROW_ID")),
+            "unexpected error: {err:?}"
+        );
+    }
+
     /// _ROW_ID + predicate, merge branch: same guarantee across a column 
merge.
     #[tokio::test]
     async fn test_evolution_read_row_id_with_predicate_merge_branch() {
diff --git a/crates/paimon/src/table/format_table_read.rs 
b/crates/paimon/src/table/format_table_read.rs
index 9adb2e83..9f0814d6 100644
--- a/crates/paimon/src/table/format_table_read.rs
+++ b/crates/paimon/src/table/format_table_read.rs
@@ -104,6 +104,10 @@ impl<'a> FormatTableRead<'a> {
     ) -> crate::Result<ArrowRecordBatchStream> {
         let core_options = self.table.schema().core_options();
         core_options.ensure_read_authorized()?;
+        // Mapping the conjunct onto the data fields drops it, so the read 
would
+        // silently ignore the filter. Guard on the read path, not the builder:
+        // `TableRead` is public and can be constructed and filtered directly.
+        super::row_id_predicate::reject_row_id_filter(&self.data_predicates, 
"format tables")?;
         let read_type = self.read_type.clone();
         let output_schema = build_target_arrow_schema(&read_type)?;
         let partition_keys = self.table.schema().partition_keys().to_vec();
diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index a9612d2a..ee0c3ecc 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -315,6 +315,12 @@ impl KeyValueFileReader {
     }
 
     pub fn read(self, data_splits: &[DataSplit]) -> 
crate::Result<ArrowRecordBatchStream> {
+        // A projected `_ROW_ID` is synthesized as all-nulls here, so the 
residual
+        // would silently drop every row rather than hit its missing-column 
guard.
+        super::row_id_predicate::reject_row_id_filter(
+            &self.config.predicates,
+            "primary-key tables",
+        )?;
         // Build the internal read type for thin-mode files.
         // Physical file schema: [_SEQUENCE_NUMBER, _VALUE_KIND, 
all_user_cols...]
         // We need: _SEQ + _VK + union(read_type, primary_keys)
@@ -703,6 +709,77 @@ mod tests {
     use parquet::file::properties::WriterProperties;
     use std::sync::Arc;
 
+    #[tokio::test]
+    async fn test_row_id_filter_on_a_primary_key_table_is_rejected() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_row_id_filter";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let row_id = crate::spec::row_id_leaf(
+            crate::spec::PredicateOperator::NotEq,
+            vec![Datum::Long(102)],
+        );
+        let mut read_builder = table.new_read_builder();
+        read_builder
+            .with_projection(&["id", "value", crate::spec::ROW_ID_FIELD_NAME])
+            .unwrap();
+        read_builder.with_filter(row_id);
+        let plan = read_builder.new_scan().plan().await.unwrap();
+        let err = read_builder
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .err()
+            .expect("a _ROW_ID filter must be rejected");
+
+        assert!(
+            matches!(&err, Error::Unsupported { message } if 
message.contains("_ROW_ID")),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_row_id_conjunct_is_not_treated_as_a_primary_key_conjunct() {
+        let fields = vec![
+            DataField::new(0, "k".to_string(), DataType::Int(IntType::new())),
+            DataField::new(1, "v".to_string(), DataType::Int(IntType::new())),
+        ];
+        let row_id =
+            crate::spec::row_id_leaf(crate::spec::PredicateOperator::Eq, 
vec![Datum::Long(5)]);
+        let on_key = PredicateBuilder::new(&fields)
+            .equal("k", Datum::Int(1))
+            .unwrap();
+
+        assert_eq!(
+            retain_primary_key_conjuncts(
+                std::slice::from_ref(&row_id),
+                &fields,
+                &["k".to_string()]
+            ),
+            Vec::new()
+        );
+        assert_eq!(
+            retain_primary_key_conjuncts(
+                &[Predicate::and(vec![row_id, on_key.clone()])],
+                &fields,
+                &["k".to_string()],
+            ),
+            vec![on_key]
+        );
+    }
+
     fn test_file_io() -> FileIO {
         FileIOBuilder::new("memory").build().unwrap()
     }
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index cce6838e..4af47790 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -308,7 +308,11 @@ struct PaimonReadBuilder<'a> {
     projection_names: Option<Vec<String>>,
     filter: NormalizedFilter,
     limit: Option<usize>,
-    row_ranges: Option<Vec<RowRange>>,
+    /// Ranges the caller set. Each `with_row_ranges` replaces them.
+    explicit_row_ranges: Option<Vec<RowRange>>,
+    /// Ranges the current filter yields, recomputed whenever it is replaced.
+    /// Kept apart so neither setter discards the other's constraint.
+    derived_row_ranges: Option<Vec<RowRange>>,
     case_sensitive: bool,
     parquet_read_budget: Option<Arc<crate::arrow::ParquetReadBudget>>,
 }
@@ -321,7 +325,8 @@ impl<'a> PaimonReadBuilder<'a> {
             projection_names: None,
             filter: NormalizedFilter::default(),
             limit: None,
-            row_ranges: None,
+            explicit_row_ranges: None,
+            derived_row_ranges: None,
             case_sensitive: true,
             parquet_read_budget: None,
         }
@@ -384,6 +389,7 @@ impl<'a> PaimonReadBuilder<'a> {
     /// the full predicate with an exact post-merge residual filter.
     pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
         self.filter = normalize_filter(self.table, filter);
+        self.derived_row_ranges = None;
         self.try_extract_row_id_ranges();
         self
     }
@@ -402,25 +408,43 @@ impl<'a> PaimonReadBuilder<'a> {
 
     /// Set row ID ranges `[from, to]` (inclusive). An empty vector selects no 
rows.
     pub fn with_row_ranges(&mut self, ranges: Vec<RowRange>) -> &mut Self {
-        self.row_ranges = Some(ranges);
+        self.explicit_row_ranges = Some(super::merge_row_ranges(ranges));
         self
     }
 
-    /// Extract `_ROW_ID` predicates from data_predicates into row_ranges.
-    /// Only runs when no explicit row_ranges have been set.
+    /// Both are independent restrictions, so a scan honours their 
intersection.
+    /// `None` on a side means unconstrained by it.
+    fn effective_row_ranges(&self) -> Option<Vec<RowRange>> {
+        match (&self.explicit_row_ranges, &self.derived_row_ranges) {
+            (Some(explicit), Some(derived)) => Some(
+                super::row_id_predicate::intersect_sorted_ranges(explicit, 
derived),
+            ),
+            (Some(only), None) | (None, Some(only)) => Some(only.clone()),
+            (None, None) => None,
+        }
+    }
+
+    /// Derive row ranges from the `_ROW_ID` conjuncts. Independent of any
+    /// explicit ranges — the two compose.
     fn try_extract_row_id_ranges(&mut self) {
-        if self.row_ranges.is_some() || self.filter.data_predicates.is_empty() 
{
+        if self.filter.data_predicates.is_empty() {
+            return;
+        }
+        // Ranges select by `first_row_id`, which only row tracking assigns.
+        // Without it nothing enforces a derived range, so a conjunct handed 
over
+        // and dropped from the residual would return every row.
+        let core_options = CoreOptions::new(self.table.schema().options());
+        if !core_options.row_tracking_enabled() && 
!core_options.data_evolution_enabled() {
             return;
         }
         let combined = Predicate::and(self.filter.data_predicates.clone());
         if let Some(ranges) = 
super::row_id_predicate::extract_row_id_ranges(&combined) {
-            self.row_ranges = Some(ranges);
-            self.filter.data_predicates = self
-                .filter
+            self.derived_row_ranges = Some(ranges);
+            // Ranges are a superset, so a conjunct leaves the residual only 
when
+            // they represent it exactly.
+            self.filter
                 .data_predicates
-                .iter()
-                .filter_map(super::row_id_predicate::remove_row_id_filter)
-                .collect();
+                .retain(|conjunct| 
!super::row_id_predicate::ranges_represent_conjunct(conjunct));
         }
     }
 
@@ -466,7 +490,7 @@ impl<'a> PaimonReadBuilder<'a> {
             self.filter.data_predicates.clone(),
             self.filter.bucket_predicate.clone(),
             self.limit,
-            self.row_ranges.clone(),
+            self.effective_row_ranges(),
         )
         
.with_projected_read_field_ids(projected_read_field_ids_with_predicates(
             &read_type,
@@ -606,11 +630,7 @@ pub(super) fn resolve_projected_fields(
         }
 
         if name == crate::spec::ROW_ID_FIELD_NAME {
-            resolved.push(DataField::new(
-                crate::spec::ROW_ID_FIELD_ID,
-                crate::spec::ROW_ID_FIELD_NAME.to_string(),
-                
crate::spec::DataType::BigInt(crate::spec::BigIntType::with_nullable(true)),
-            ));
+            resolved.push(crate::spec::row_id_data_field());
             continue;
         }
 
@@ -659,11 +679,16 @@ fn projected_read_field_ids_with_predicates(
     table_fields: &[DataField],
 ) -> Option<HashSet<i32>> {
     let mut field_ids = projected_read_field_ids(read_type)?;
-    let mut predicate_indices = Vec::new();
+    let mut refs = Vec::new();
     for predicate in predicates {
-        crate::arrow::residual::collect_predicate_field_indices(predicate, 
&mut predicate_indices);
+        crate::arrow::residual::collect_predicate_leaf_refs(predicate, &mut 
refs);
     }
-    for index in predicate_indices {
+    for (name, index) in refs {
+        // Not in `table_fields`, and excluded from this set anyway — files 
never
+        // list it in `write_cols`.
+        if crate::spec::is_row_id_column(name) {
+            continue;
+        }
         let Some(field) = table_fields.get(index) else {
             // A malformed predicate must not make scan planning discard files.
             return None;
@@ -757,6 +782,27 @@ mod tests {
         )
     }
 
+    fn row_id_table(row_tracking: bool, data_evolution: bool) -> Table {
+        let file_io = FileIOBuilder::new("file").build().unwrap();
+        let mut builder = Schema::builder()
+            .column("dt", DataType::VarChar(VarCharType::string_type()))
+            .column("id", DataType::Int(IntType::new()))
+            .partition_keys(["dt"]);
+        if row_tracking {
+            builder = builder.option("row-tracking.enabled", "true");
+        }
+        if data_evolution {
+            builder = builder.option("data-evolution.enabled", "true");
+        }
+        Table::new(
+            file_io,
+            Identifier::new("default", "t"),
+            "/tmp/test-read-builder-rowid".to_string(),
+            TableSchema::new(0, &builder.build().unwrap()),
+            None,
+        )
+    }
+
     fn simple_table() -> Table {
         let file_io = FileIOBuilder::new("file").build().unwrap();
         let table_schema = TableSchema::new(
@@ -783,7 +829,10 @@ mod tests {
         let mut builder = table.new_read_builder();
         builder.with_row_ranges(Vec::new());
 
-        assert_eq!(paimon_builder(&builder).row_ranges, Some(Vec::new()));
+        assert_eq!(
+            paimon_builder(&builder).effective_row_ranges(),
+            Some(Vec::new())
+        );
     }
 
     #[tokio::test]
@@ -953,6 +1002,298 @@ mod tests {
         );
     }
 
+    use crate::table::RowRange;
+
+    fn row_id_leaf(op: crate::spec::PredicateOperator, v: i64) -> Predicate {
+        crate::spec::row_id_leaf(op, vec![crate::spec::Datum::Long(v)])
+    }
+
+    #[test]
+    fn test_replacing_a_filter_drops_the_range_it_derived() {
+        use crate::spec::PredicateOperator;
+        let table = row_id_table(true, false);
+        let mut builder = PaimonReadBuilder::new(&table);
+
+        builder.with_filter(row_id_leaf(PredicateOperator::Eq, 10));
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(10, 10)])
+        );
+
+        builder.with_filter(
+            PredicateBuilder::new(table.schema().fields())
+                .equal("id", crate::spec::Datum::Int(7))
+                .unwrap(),
+        );
+        assert_eq!(builder.effective_row_ranges(), None);
+    }
+
+    #[test]
+    fn test_range_setters_do_not_consume_each_other() {
+        use crate::spec::PredicateOperator;
+        let table = row_id_table(true, false);
+        let mut builder = PaimonReadBuilder::new(&table);
+
+        builder.with_filter(row_id_leaf(PredicateOperator::GtEq, 10));
+        builder.with_row_ranges(vec![RowRange::new(0, 20)]);
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(10, 20)])
+        );
+
+        builder.with_row_ranges(vec![RowRange::new(0, 30)]);
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(10, 30)])
+        );
+
+        builder.with_filter(
+            PredicateBuilder::new(table.schema().fields())
+                .equal("id", crate::spec::Datum::Int(7))
+                .unwrap(),
+        );
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(0, 30)])
+        );
+    }
+
+    #[test]
+    fn test_an_explicit_range_survives_a_filter_replacement() {
+        use crate::spec::PredicateOperator;
+        let table = row_id_table(true, false);
+        let mut builder = PaimonReadBuilder::new(&table);
+
+        builder.with_row_ranges(vec![RowRange::new(0, 20)]);
+        builder.with_filter(row_id_leaf(PredicateOperator::Eq, 10));
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(10, 10)])
+        );
+
+        builder.with_filter(
+            PredicateBuilder::new(table.schema().fields())
+                .equal("id", crate::spec::Datum::Int(7))
+                .unwrap(),
+        );
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(0, 20)])
+        );
+    }
+
+    #[test]
+    fn test_inexactly_extracted_row_id_conjuncts_stay_residuals() {
+        use crate::spec::PredicateOperator;
+        let table = row_id_table(true, false);
+        let mut builder = PaimonReadBuilder::new(&table);
+
+        let contradiction = Predicate::and(vec![
+            row_id_leaf(PredicateOperator::Eq, 1),
+            row_id_leaf(PredicateOperator::NotEq, 1),
+        ]);
+        builder.with_filter(contradiction);
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(1, 1)])
+        );
+        assert_eq!(
+            builder.filter.data_predicates,
+            vec![row_id_leaf(PredicateOperator::NotEq, 1)]
+        );
+
+        let mut exact = PaimonReadBuilder::new(&table);
+        exact.with_filter(row_id_leaf(PredicateOperator::GtEq, 10));
+        assert_eq!(
+            exact.effective_row_ranges(),
+            Some(vec![RowRange::new(10, i64::MAX)])
+        );
+        assert!(exact.filter.data_predicates.is_empty());
+    }
+
+    #[test]
+    fn test_a_mixed_conjunct_is_never_replaced_by_ranges() {
+        use crate::spec::{Datum, PredicateOperator};
+        let table = row_id_table(true, false);
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let disjunction = Predicate::or(vec![
+            Predicate::and(vec![
+                row_id_leaf(PredicateOperator::Eq, 1),
+                pb.equal("id", Datum::Int(7)).unwrap(),
+            ]),
+            pb.equal("dt", Datum::String("x".into())).unwrap(),
+        ]);
+        let mut builder = PaimonReadBuilder::new(&table);
+        builder.with_filter(Predicate::and(vec![
+            row_id_leaf(PredicateOperator::GtEq, 10),
+            disjunction.clone(),
+        ]));
+
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(10, i64::MAX)])
+        );
+        assert_eq!(builder.filter.data_predicates, vec![disjunction]);
+    }
+
+    #[test]
+    fn test_a_nested_empty_row_id_branch_does_not_widen_the_ranges() {
+        use crate::spec::PredicateOperator;
+        let table = row_id_table(true, false);
+        let unsatisfiable = Predicate::or(vec![
+            row_id_leaf(PredicateOperator::Gt, i64::MAX),
+            row_id_leaf(PredicateOperator::Lt, 0),
+        ]);
+        let mut builder = PaimonReadBuilder::new(&table);
+        builder.with_filter(Predicate::or(vec![
+            Predicate::and(vec![row_id_leaf(PredicateOperator::Eq, 6), 
unsatisfiable]),
+            row_id_leaf(PredicateOperator::Eq, 7),
+        ]));
+
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(7, 7)])
+        );
+        assert!(builder.filter.data_predicates.is_empty());
+    }
+
+    #[test]
+    fn test_a_format_table_rejects_a_row_id_filter() {
+        use crate::spec::PredicateOperator;
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .option("type", "format-table")
+                .option("file.format", "parquet")
+                .build()
+                .unwrap(),
+        );
+        let table = Table::new(
+            file_io,
+            Identifier::new("default", "ft"),
+            "memory:/ft".to_string(),
+            table_schema,
+            None,
+        );
+        let mut builder = ReadBuilder::new(&table);
+        builder.with_filter(row_id_leaf(PredicateOperator::NotEq, 5));
+        let err = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(&[])
+            .err()
+            .expect("a _ROW_ID filter must be rejected");
+
+        assert!(matches!(err, crate::Error::Unsupported { .. }));
+    }
+
+    #[test]
+    fn test_an_empty_row_id_disjunction_stays_a_residual() {
+        use crate::spec::PredicateOperator;
+        let table = row_id_table(true, false);
+        let unsatisfiable = Predicate::or(vec![
+            row_id_leaf(PredicateOperator::Gt, i64::MAX),
+            row_id_leaf(PredicateOperator::Lt, 0),
+        ]);
+        let mut builder = PaimonReadBuilder::new(&table);
+        builder.with_filter(Predicate::and(vec![
+            row_id_leaf(PredicateOperator::Eq, 5),
+            unsatisfiable.clone(),
+        ]));
+
+        assert_eq!(builder.effective_row_ranges(), Some(Vec::new()));
+        assert_eq!(builder.filter.data_predicates, vec![unsatisfiable]);
+    }
+
+    #[test]
+    fn test_extraction_needs_a_table_whose_files_carry_row_ids() {
+        use crate::spec::PredicateOperator;
+        let plain_table = row_id_table(false, false);
+        let mut plain = PaimonReadBuilder::new(&plain_table);
+        plain.with_filter(row_id_leaf(PredicateOperator::GtEq, 102));
+        assert_eq!(
+            plain.effective_row_ranges(),
+            None,
+            "nothing to derive ranges from"
+        );
+        assert_eq!(
+            plain.filter.data_predicates.len(),
+            1,
+            "the conjunct must stay so the read can reject it"
+        );
+
+        for (row_tracking, data_evolution) in [(true, false), (false, true), 
(true, true)] {
+            let table = row_id_table(row_tracking, data_evolution);
+            let mut builder = PaimonReadBuilder::new(&table);
+            builder.with_filter(row_id_leaf(PredicateOperator::GtEq, 102));
+            assert_eq!(
+                builder.effective_row_ranges(),
+                Some(vec![RowRange::new(102, i64::MAX)]),
+                "row_tracking={row_tracking} data_evolution={data_evolution}"
+            );
+            assert!(builder.filter.data_predicates.is_empty());
+        }
+    }
+
+    #[test]
+    fn test_an_explicit_range_intersects_a_derived_one() {
+        use crate::spec::PredicateOperator;
+        let table = row_id_table(true, false);
+        let mut builder = PaimonReadBuilder::new(&table);
+        builder.with_filter(Predicate::or(vec![
+            row_id_leaf(PredicateOperator::Eq, 1),
+            row_id_leaf(PredicateOperator::Eq, 8),
+        ]));
+        assert!(
+            builder.filter.data_predicates.is_empty(),
+            "handed to ranges"
+        );
+
+        builder.with_row_ranges(vec![RowRange::new(0, 5)]);
+
+        assert_eq!(
+            builder.effective_row_ranges(),
+            Some(vec![RowRange::new(1, 1)]),
+            "row 8 is outside the explicit range, row 1 satisfies both"
+        );
+    }
+
+    #[test]
+    fn test_row_id_filter_never_projects_onto_a_partition_key() {
+        let table = simple_table();
+        let row_id = crate::spec::row_id_leaf(
+            crate::spec::PredicateOperator::GtEq,
+            vec![crate::spec::Datum::Long(10)],
+        );
+
+        let (partition_predicate, data_predicates) =
+            super::split_scan_predicates(&table, row_id.clone());
+        assert_eq!(partition_predicate, None);
+        assert_eq!(data_predicates, vec![row_id.clone()]);
+
+        assert!(!ReadBuilder::new(&table).is_exact_filter_pushdown(&row_id));
+    }
+
+    #[test]
+    fn test_projected_read_field_ids_ignore_system_predicate_fields() {
+        let fields = vec![
+            DataField::new(1, "id".to_string(), DataType::Int(IntType::new())),
+            DataField::new(2, "payload".to_string(), 
DataType::Int(IntType::new())),
+        ];
+        let read_type = Some(vec![fields[1].clone()]);
+        let row_id = crate::spec::row_id_leaf(
+            crate::spec::PredicateOperator::Eq,
+            vec![crate::spec::Datum::Long(1)],
+        );
+
+        assert_eq!(
+            super::projected_read_field_ids_with_predicates(&read_type, 
&[row_id], &fields),
+            Some(HashSet::from([2]))
+        );
+    }
+
     #[test]
     fn test_with_projection_validates_unknown_projection() {
         // A column that cannot match under any case sensitivity is an obvious
@@ -1635,6 +1976,7 @@ mod tests {
             &Schema::builder()
                 .column("id", DataType::Int(IntType::new()))
                 .column("value", DataType::Int(IntType::new()))
+                .option("row-tracking.enabled", "true")
                 .build()
                 .unwrap(),
         );
@@ -1670,8 +2012,8 @@ mod tests {
 
         // _ROW_ID predicates should be extracted into row_ranges
         let inner = paimon_builder(&builder);
-        assert!(inner.row_ranges.is_some());
-        let ranges = inner.row_ranges.as_ref().unwrap();
+        assert!(inner.effective_row_ranges().is_some());
+        let ranges = inner.effective_row_ranges().unwrap();
         assert_eq!(ranges.len(), 1);
         assert_eq!(ranges[0].from(), 10);
         assert_eq!(ranges[0].to(), 20);
@@ -1716,7 +2058,7 @@ mod tests {
         builder.with_filter(filter);
 
         // Explicit row_ranges should be preserved, not overwritten
-        let ranges = paimon_builder(&builder).row_ranges.as_ref().unwrap();
+        let ranges = paimon_builder(&builder).effective_row_ranges().unwrap();
         assert_eq!(ranges.len(), 1);
         assert_eq!(ranges[0].from(), 0);
         assert_eq!(ranges[0].to(), 5);
diff --git a/crates/paimon/src/table/row_id_predicate.rs 
b/crates/paimon/src/table/row_id_predicate.rs
index 353ed69c..b6253fef 100644
--- a/crates/paimon/src/table/row_id_predicate.rs
+++ b/crates/paimon/src/table/row_id_predicate.rs
@@ -19,7 +19,7 @@
 //!
 //! Reference: 
[org.apache.paimon.predicate.RowIdPredicateVisitor](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/predicate/RowIdPredicateVisitor.java)
 
-use crate::spec::{Datum, Predicate, PredicateOperator, ROW_ID_FIELD_NAME};
+use crate::spec::{is_row_id_column, Datum, Predicate, PredicateOperator};
 use crate::table::RowRange;
 
 /// Extract row ranges from `_ROW_ID` predicates in the given filter.
@@ -31,7 +31,7 @@ pub(crate) fn extract_row_id_ranges(predicate: &Predicate) -> 
Option<Vec<RowRang
             op,
             literals,
             ..
-        } if column == ROW_ID_FIELD_NAME => leaf_to_ranges(*op, literals),
+        } if is_row_id_column(column) => leaf_to_ranges(*op, literals),
         Predicate::And(children) => {
             // AND: intersect all _ROW_ID ranges
             let mut result: Option<Vec<RowRange>> = None;
@@ -46,48 +46,82 @@ pub(crate) fn extract_row_id_ranges(predicate: &Predicate) 
-> Option<Vec<RowRang
             result
         }
         Predicate::Or(children) => {
-            // OR: union all _ROW_ID ranges (all children must have _ROW_ID 
predicates)
+            // OR: union all _ROW_ID ranges (all children must have _ROW_ID 
predicates).
+            // An empty union is `Some(vec![])`, not `None`: the branches 
convert
+            // to "no rows", and reporting that as "nothing extracted" would 
let
+            // an enclosing AND skip the subtree and overstate its ranges.
             let mut all_ranges: Vec<RowRange> = Vec::new();
             for child in children {
                 let ranges = extract_row_id_ranges(child)?;
                 all_ranges.extend(ranges);
             }
-            if all_ranges.is_empty() {
-                None
-            } else {
-                Some(super::merge_row_ranges(all_ranges))
-            }
+            Some(super::merge_row_ranges(all_ranges))
         }
         _ => None,
     }
 }
 
-/// Remove `_ROW_ID` predicates from a filter, returning the remaining filter.
-/// Returns `None` if the entire filter is a `_ROW_ID` predicate.
-pub(crate) fn remove_row_id_filter(predicate: &Predicate) -> Option<Predicate> 
{
+/// Whether the extracted row ranges represent `conjunct` exactly, so it can be
+/// dropped from the residual.
+///
+/// Two things must hold. The conjunct must be built entirely from convertible
+/// `_ROW_ID` conditions — extraction ignores whatever it cannot convert, so 
its
+/// ranges are merely a superset and anything mixed leaves a remainder they do
+/// not carry. And extraction must actually have produced ranges *for this
+/// conjunct*: an `Or` whose branches are all empty yields `None`, which the
+/// enclosing `And` silently skips, so its ranges say nothing about it.
+pub(crate) fn ranges_represent_conjunct(conjunct: &Predicate) -> bool {
+    convertible_row_id_only(conjunct)
+        && extract_row_id_ranges(conjunct).is_some_and(|ranges| 
!ranges.is_empty())
+}
+
+fn convertible_row_id_only(predicate: &Predicate) -> bool {
     match predicate {
-        Predicate::Leaf { column, .. } if column == ROW_ID_FIELD_NAME => None,
-        Predicate::And(children) => {
-            let filtered: Vec<Predicate> =
-                children.iter().filter_map(remove_row_id_filter).collect();
-            match filtered.len() {
-                0 => None,
-                1 => Some(filtered.into_iter().next().unwrap()),
-                _ => Some(Predicate::and(filtered)),
-            }
+        Predicate::Leaf {
+            column,
+            op,
+            literals,
+            ..
+        } => is_row_id_column(column) && leaf_to_ranges(*op, 
literals).is_some(),
+        Predicate::And(children) | Predicate::Or(children) => {
+            children.iter().all(convertible_row_id_only)
         }
-        Predicate::Or(children) => {
-            let filtered: Vec<Predicate> =
-                children.iter().filter_map(remove_row_id_filter).collect();
-            if filtered.len() != children.len() {
-                // If any child was entirely _ROW_ID, the OR semantics change;
-                // conservatively keep the whole OR.
-                Some(predicate.clone())
-            } else {
-                Some(Predicate::or(filtered))
-            }
+        _ => false,
+    }
+}
+
+/// The error for a read that cannot evaluate a `_ROW_ID` predicate. `read` 
names
+/// the kind of read.
+///
+/// Only data-evolution reads attach the column; everywhere else the predicate 
is
+/// unenforceable, and every alternative is a silent wrong answer — a dropped
+/// conjunct, an all-null synthesized column, or a scan skipped entirely.
+pub(crate) fn unsupported_row_id_filter(read: &str) -> crate::Error {
+    crate::Error::Unsupported {
+        message: format!(
+            "filtering on '_ROW_ID' is not supported by {read}; it is 
available on \
+             data-evolution reads, or via row ranges"
+        ),
+    }
+}
+
+/// Reject a `_ROW_ID` predicate on a read that cannot synthesize row ids.
+pub(crate) fn reject_row_id_filter(predicates: &[Predicate], read: &str) -> 
crate::Result<()> {
+    if predicates.iter().any(references_row_id) {
+        return Err(unsupported_row_id_filter(read));
+    }
+    Ok(())
+}
+
+/// Whether any leaf of `predicate` references `_ROW_ID`.
+pub(crate) fn references_row_id(predicate: &Predicate) -> bool {
+    match predicate {
+        Predicate::Leaf { column, .. } => is_row_id_column(column),
+        Predicate::And(children) | Predicate::Or(children) => {
+            children.iter().any(references_row_id)
         }
-        other => Some(other.clone()),
+        Predicate::Not(inner) => references_row_id(inner),
+        Predicate::AlwaysTrue | Predicate::AlwaysFalse => false,
     }
 }
 
@@ -131,10 +165,12 @@ fn leaf_to_ranges(op: PredicateOperator, literals: 
&[Datum]) -> Option<Vec<RowRa
             Some(vec![RowRange::new(0, v - 1)])
         }
         PredicateOperator::In => {
+            // EVERY literal, or none: ranges standing for only part of the 
leaf
+            // would still read as the whole of it and drop the conjunct.
             let mut ranges: Vec<RowRange> = literals
                 .iter()
-                .filter_map(|d| datum_to_i64(d).map(|v| RowRange::new(v, v)))
-                .collect();
+                .map(|d| datum_to_i64(d).map(|v| RowRange::new(v, v)))
+                .collect::<Option<Vec<_>>>()?;
             if ranges.is_empty() {
                 return None;
             }
@@ -146,6 +182,11 @@ fn leaf_to_ranges(op: PredicateOperator, literals: 
&[Datum]) -> Option<Vec<RowRa
 }
 
 /// Intersect two sorted range lists.
+/// Intersect two range lists that are already sorted and merged.
+pub(crate) fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) -> 
Vec<RowRange> {
+    intersect_range_lists(a, b)
+}
+
 fn intersect_range_lists(a: &[RowRange], b: &[RowRange]) -> Vec<RowRange> {
     let mut result = Vec::new();
     let (mut i, mut j) = (0, 0);
@@ -166,17 +207,42 @@ fn intersect_range_lists(a: &[RowRange], b: &[RowRange]) 
-> Vec<RowRange> {
 
 #[cfg(test)]
 mod tests {
+
+    #[test]
+    fn test_a_partially_convertible_in_is_not_converted_at_all() {
+        let partial = row_id_leaf(
+            PredicateOperator::In,
+            vec![Datum::Long(5), Datum::String("7".into())],
+        );
+        assert_eq!(extract_row_id_ranges(&partial), None);
+        assert!(!ranges_represent_conjunct(&partial));
+
+        let whole = row_id_leaf(PredicateOperator::In, vec![Datum::Long(5), 
Datum::Long(7)]);
+        assert_eq!(
+            extract_row_id_ranges(&whole),
+            Some(vec![RowRange::new(5, 5), RowRange::new(7, 7)])
+        );
+        assert!(ranges_represent_conjunct(&whole));
+    }
     use super::*;
+    use crate::spec::row_id_leaf;
     use crate::spec::{BigIntType, DataType};
 
-    fn row_id_leaf(op: PredicateOperator, literals: Vec<Datum>) -> Predicate {
-        Predicate::Leaf {
-            column: ROW_ID_FIELD_NAME.to_string(),
+    #[test]
+    fn test_references_row_id_finds_nested_leaves() {
+        let other = Predicate::Leaf {
+            column: "v".to_string(),
             index: 0,
             data_type: DataType::BigInt(BigIntType::new()),
-            op,
-            literals,
-        }
+            op: PredicateOperator::Eq,
+            literals: vec![Datum::Long(7)],
+        };
+        let nested = Predicate::Not(Box::new(Predicate::or(vec![
+            row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(1)]),
+            other.clone(),
+        ])));
+        assert!(references_row_id(&nested));
+        assert!(!references_row_id(&other));
     }
 
     fn data_leaf() -> Predicate {
@@ -240,24 +306,26 @@ mod tests {
     }
 
     #[test]
-    fn test_remove_row_id_filter_leaf() {
-        let p = row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(10)]);
-        assert!(remove_row_id_filter(&p).is_none());
-    }
-
-    #[test]
-    fn test_remove_row_id_filter_and() {
-        let p = Predicate::and(vec![
-            row_id_leaf(PredicateOperator::GtEq, vec![Datum::Long(10)]),
+    fn test_ranges_represent_only_a_pure_row_id_conjunct() {
+        assert!(ranges_represent_conjunct(&row_id_leaf(
+            PredicateOperator::GtEq,
+            vec![Datum::Long(10)]
+        )));
+        assert!(!ranges_represent_conjunct(&row_id_leaf(
+            PredicateOperator::NotEq,
+            vec![Datum::Long(10)]
+        )));
+        assert!(!ranges_represent_conjunct(&data_leaf()));
+        assert!(!ranges_represent_conjunct(&Predicate::or(vec![
+            Predicate::and(vec![
+                row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(1)]),
+                data_leaf(),
+            ]),
             data_leaf(),
-        ]);
-        let result = remove_row_id_filter(&p).unwrap();
-        assert_eq!(result, data_leaf());
-    }
-
-    #[test]
-    fn test_remove_row_id_filter_keeps_non_row_id() {
-        let p = data_leaf();
-        assert_eq!(remove_row_id_filter(&p).unwrap(), data_leaf());
+        ])));
+        assert!(ranges_represent_conjunct(&Predicate::or(vec![
+            row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(1)]),
+            row_id_leaf(PredicateOperator::Eq, vec![Datum::Long(2)]),
+        ])));
     }
 }
diff --git a/crates/paimon/src/table/stats_filter.rs 
b/crates/paimon/src/table/stats_filter.rs
index 2eb18e55..7de875c5 100644
--- a/crates/paimon/src/table/stats_filter.rs
+++ b/crates/paimon/src/table/stats_filter.rs
@@ -23,7 +23,9 @@ use crate::predicate_stats::{
     data_leaf_may_match, data_leaf_must_match, missing_field_may_match, 
missing_field_must_match,
     predicates_may_match_with_schema, StatsAccessor,
 };
-use crate::spec::{extract_datum, BinaryRow, DataField, DataFileMeta, DataType, 
Datum, Predicate};
+use crate::spec::{
+    extract_datum, is_row_id_column, BinaryRow, DataField, DataFileMeta, 
DataType, Datum, Predicate,
+};
 use std::collections::HashMap;
 use std::sync::Arc;
 
@@ -399,6 +401,8 @@ fn data_evolution_predicate_may_match(
             file_stats,
             row_count,
         ),
+        // `_ROW_ID` has no column stats, so never prune a group on it.
+        Predicate::Leaf { column, .. } if is_row_id_column(column) => true,
         Predicate::Leaf {
             index,
             data_type,
@@ -462,6 +466,8 @@ fn data_evolution_predicate_must_match(
             file_stats,
             row_count,
         ),
+        // Stats cannot decide `_ROW_ID`, so it never provably matches.
+        Predicate::Leaf { column, .. } if is_row_id_column(column) => false,
         Predicate::Leaf {
             index,
             data_type,
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 43922255..471606a1 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -384,13 +384,9 @@ fn split_row_ranges_for_files(
             .flat_map(|file| intersect_ranges_with_file(ranges, file))
             .collect(),
     );
-    if split_ranges.is_empty() {
-        return Err(crate::Error::DataInvalid {
-            message: "Planned data-evolution split does not overlap selected 
row ranges"
-                .to_string(),
-            source: None,
-        });
-    }
+    // Empty means this split holds none of the selected rows. Only data
+    // evolution prunes such splits away up front (`RowRangeIndex`), so on a
+    // row-tracking table they reach here legitimately and the caller drops 
them.
     Ok(Some(split_ranges))
 }
 
@@ -1462,10 +1458,16 @@ impl<'a> PaimonTableScan<'a> {
                         .to_string(),
             });
         }
-        if self.row_ranges.is_some() {
+        // Both forms: without `first_row_id` a filter stays an ordinary data
+        // predicate instead of becoming a row range.
+        if self.row_ranges.is_some()
+            || self
+                .data_predicates
+                .iter()
+                .any(super::row_id_predicate::references_row_id)
+        {
             return Err(crate::Error::Unsupported {
-                message: "Batch incremental Diff does not support _ROW_ID 
row-range filters"
-                    .to_string(),
+                message: "Batch incremental Diff does not support _ROW_ID 
filters".to_string(),
             });
         }
         // A limit hint cannot be pushed into either side of a Diff: truncating
@@ -1966,6 +1968,12 @@ impl<'a> PaimonTableScan<'a> {
 
                 let split_row_ranges =
                     
split_row_ranges_for_files(effective_row_ranges.as_deref(), &file_group)?;
+                if split_row_ranges
+                    .as_deref()
+                    .is_some_and(<[RowRange]>::is_empty)
+                {
+                    continue;
+                }
 
                 let mut builder = DataSplitBuilder::new()
                     .with_snapshot(snapshot_id)
@@ -2272,9 +2280,11 @@ mod tests {
             if message.contains("missing or invalid row-id range")));
 
         let outside = make_evo_file("outside", 1, 2, 0, Some(10));
-        let error = split_row_ranges_for_files(Some(&ranges), 
&[outside]).unwrap_err();
-        assert!(matches!(error, Error::DataInvalid { message, .. }
-            if message.contains("does not overlap selected row ranges")));
+        assert_eq!(
+            split_row_ranges_for_files(Some(&ranges), &[outside]).unwrap(),
+            Some(Vec::new()),
+            "a split holding none of the selected rows is dropped, not an 
error"
+        );
     }
 
     fn data_evolution_test_table(table_path: &str, schema: TableSchema) -> 
Table {
@@ -3788,6 +3798,30 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn test_data_evolution_group_is_not_pruned_by_a_row_id_predicate() {
+        let fields = int_field();
+        let file = test_data_file_meta(
+            int_stats_row(Some(10)),
+            int_stats_row(Some(20)),
+            vec![Some(0)],
+            5,
+        );
+        let row_id =
+            crate::spec::row_id_leaf(crate::spec::PredicateOperator::GtEq, 
vec![Datum::Long(100)]);
+
+        assert!(data_evolution_group_matches_predicates(
+            std::slice::from_ref(&file),
+            std::slice::from_ref(&row_id),
+            &fields,
+        ));
+        assert!(data_evolution_group_matches_predicates(
+            &[file],
+            &[Predicate::negate(row_id)],
+            &fields,
+        ));
+    }
+
     #[test]
     fn test_data_evolution_group_matches_not_prunes_when_inner_must_match() {
         let fields = int_field();
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index 3866c6a8..1295db93 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -23,8 +23,8 @@ use crate::lumina::{
     is_lumina_index_type, LuminaIndexMeta, LuminaVectorIndexOptions, 
LuminaVectorMetric,
 };
 use crate::spec::{
-    BigIntType, CoreOptions, DataField, DataType, FileKind, 
GlobalIndexSearchMode, IndexFileMeta,
-    IndexManifest, IndexManifestEntry, Predicate, ROW_ID_FIELD_ID, 
ROW_ID_FIELD_NAME,
+    row_id_data_field, CoreOptions, DataField, DataType, FileKind, 
GlobalIndexSearchMode,
+    IndexFileMeta, IndexManifest, IndexManifestEntry, Predicate, 
ROW_ID_FIELD_NAME,
 };
 use crate::table::bucket_filter::split_partition_and_data_predicates;
 use crate::table::data_file_reader::DataFileReader;
@@ -2148,19 +2148,6 @@ pub(crate) fn reorder_and_strip_position(
     Ok(vec![projected])
 }
 
-/// The `_ROW_ID` field to append to a data-evolution read type so the reader
-/// fills each row's global id. Mirrors the field the `DataEvolutionReader`
-/// recognizes (Int64 / `BigInt`, nullable): a data file lacking `first_row_id`
-/// yields nulls here, which `attach_scores_by_row_id` then fails loud on 
rather
-/// than mis-aligning scores.
-fn row_id_data_field() -> DataField {
-    DataField::new(
-        ROW_ID_FIELD_ID,
-        ROW_ID_FIELD_NAME.to_string(),
-        DataType::BigInt(BigIntType::with_nullable(true)),
-    )
-}
-
 /// Collect materialized DE rows, join each row's `(rank, score)` by its global
 /// `_ROW_ID`, reorder to the search rank order, append the 
`__paimon_search_score`
 /// column, and drop `_ROW_ID`. Every row must map to a search candidate and 
the

Reply via email to