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 fae8acff [core] Use BTree indexes for upsert key lookups (#1198)
fae8acff is described below

commit fae8acff309c6b35e58ca7beb497ae0fb05f1c2e
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 22 19:09:55 2026 +0800

    [core] Use BTree indexes for upsert key lookups (#1198)
---
 crates/paimon/src/btree/composite_predicate.rs     |  75 +-
 crates/paimon/src/btree/composite_tests.rs         | 431 ++++++++-
 crates/paimon/src/btree/reader.rs                  |  40 +-
 crates/paimon/src/table/global_index_freshness.rs  | 735 +++++++++++++++
 .../src/table/global_index_scanner/composite.rs    |   9 +-
 .../paimon/src/table/global_index_scanner/tests.rs |  97 ++
 crates/paimon/src/table/key_normalization.rs       |  59 ++
 crates/paimon/src/table/mod.rs                     |   1 +
 crates/paimon/src/table/table_upsert.rs            | 985 +++++++++++++++++++--
 9 files changed, 2331 insertions(+), 101 deletions(-)

diff --git a/crates/paimon/src/btree/composite_predicate.rs 
b/crates/paimon/src/btree/composite_predicate.rs
index cb96f520..0727c925 100644
--- a/crates/paimon/src/btree/composite_predicate.rs
+++ b/crates/paimon/src/btree/composite_predicate.rs
@@ -22,7 +22,10 @@ use super::CompositeKeyCodec;
 use crate::spec::{DataField, Datum, Predicate, PredicateOperator};
 use crate::Result;
 use std::cmp::Ordering;
+use std::collections::HashSet;
 
+// Bound Cartesian expansion, while allowing a single IN domain to grow
+// linearly with its input, including constant prefixes and suffixes.
 pub(crate) const MAX_INTERVALS: usize = 256;
 type Component = Option<Vec<u8>>;
 
@@ -56,7 +59,7 @@ impl CompositeBound {
         })
     }
 
-    fn compare(&self, other: &Self, codec: &CompositeKeyCodec) -> 
Result<Ordering> {
+    pub(super) fn compare(&self, other: &Self, codec: &CompositeKeyCodec) -> 
Result<Ordering> {
         for i in 0..self.values.len().min(other.values.len()) {
             let order = codec.compare_component(
                 i,
@@ -175,49 +178,69 @@ impl CompositePlan {
             } else {
                 literals.iter().cloned().map(Some).collect()
             };
+            // Other IN predicates on this column must also avoid a linear
+            // literal scan for every candidate. Normalized component bytes
+            // preserve comparison equality, including canonical NaNs.
+            let in_domains = column
+                .iter()
+                .map(|leaf| match leaf {
+                    Predicate::Leaf {
+                        op: PredicateOperator::In,
+                        literals,
+                        ..
+                    } if !std::ptr::eq(leaf, selected) => Some(
+                        literals
+                            .iter()
+                            .map(|value| codec.serialize_component(i, value))
+                            .collect::<HashSet<_>>(),
+                    ),
+                    _ => None,
+                })
+                .collect::<Vec<_>>();
             let mut domain: Vec<Option<Datum>> = Vec::new();
+            let mut seen = HashSet::new();
             for candidate in candidates {
                 let encoded = candidate.as_ref().map(|v| 
codec.serialize_component(i, v));
                 let mut matches = true;
-                for leaf in &column {
-                    if !matches_leaf(&codec, i, encoded.as_deref(), leaf)? {
+                for (leaf, in_domain) in column.iter().zip(&in_domains) {
+                    // Candidates already come from this leaf. Rechecking an
+                    // IN against itself would make planning quadratic.
+                    if std::ptr::eq(leaf, selected) {
+                        continue;
+                    }
+                    let satisfies = if let Some(values) = in_domain {
+                        encoded.as_ref().is_some_and(|value| 
values.contains(value))
+                    } else {
+                        matches_leaf(&codec, i, encoded.as_deref(), leaf)?
+                    };
+                    if !satisfies {
                         matches = false;
                         break;
                     }
                 }
-                if matches {
-                    let mut duplicate = false;
-                    for value in &domain {
-                        let value = value.as_ref().map(|v| 
codec.serialize_component(i, v));
-                        if codec.compare_component(i, encoded.as_deref(), 
value.as_deref())?
-                            == Ordering::Equal
-                        {
-                            duplicate = true;
-                            break;
-                        }
-                    }
-                    if !duplicate {
-                        domain.push(candidate);
-                    }
-                    if domain.len() > MAX_INTERVALS {
-                        break;
-                    }
+                if matches && seen.insert(encoded) {
+                    domain.push(candidate);
                 }
             }
-            equal_columns += 1;
             if domain.is_empty() {
                 return Ok(Some(Self {
                     fields: fields.to_vec(),
                     codec,
                     intervals: Vec::new(),
-                    bound_columns: equal_columns,
-                    equal_columns,
+                    bound_columns: equal_columns + 1,
+                    equal_columns: equal_columns + 1,
                 }));
             }
-            if prefixes.len() * domain.len() > MAX_INTERVALS {
-                return Ok(None);
+            if prefixes.len() > 1
+                && domain.len() > 1
+                && prefixes.len() > MAX_INTERVALS / domain.len()
+            {
+                // Keep the bound prefix and leave this column and its suffix
+                // for residual filtering, without expanding a Cartesian 
product.
+                break;
             }
-            let mut expanded = Vec::new();
+            equal_columns += 1;
+            let mut expanded = Vec::with_capacity(prefixes.len() * 
domain.len());
             for prefix in prefixes {
                 for value in &domain {
                     let mut next = prefix.clone();
diff --git a/crates/paimon/src/btree/composite_tests.rs 
b/crates/paimon/src/btree/composite_tests.rs
index ffc090af..cfe85e84 100644
--- a/crates/paimon/src/btree/composite_tests.rs
+++ b/crates/paimon/src/btree/composite_tests.rs
@@ -318,12 +318,14 @@ fn composite_interval_expansion_and_residual_columns() {
             .len(),
         1
     );
-    let too_many = b.is_in("item", 
(0..257).map(Datum::Int).collect()).unwrap();
+    let many = b.is_in("item", (0..257).map(Datum::Int).collect()).unwrap();
     let predicate = Predicate::and(vec![
         b.equal("category", Datum::String("a".into())).unwrap(),
-        too_many,
+        many,
     ]);
-    assert!(CompositePlan::plan(&fields, &predicate).unwrap().is_none());
+    let plan = CompositePlan::plan(&fields, &predicate).unwrap().unwrap();
+    assert_eq!(plan.intervals.len(), 257);
+    assert_eq!(plan.equal_columns, 2);
     let predicate = Predicate::and(vec![
         b.greater_than("category", Datum::String("a".into()))
             .unwrap(),
@@ -348,6 +350,429 @@ fn composite_interval_expansion_and_residual_columns() {
         .is_empty());
 }
 
+fn integer_fields() -> Vec<DataField> {
+    (0..3)
+        .map(|i| DataField::new(i, format!("k{i}"), 
DataType::Int(IntType::new())))
+        .collect()
+}
+
+async fn reader_for_tuples(
+    fields: &[DataField],
+    tuples: &[Vec<Option<Datum>>],
+) -> BTreeIndexReader<super::key_serde::KeyComparator> {
+    let codec = CompositeKeyCodec::new(fields);
+    let mut entries = tuples
+        .iter()
+        .enumerate()
+        .map(|(id, tuple)| (codec.serialize(tuple).unwrap(), id as i64))
+        .collect::<Vec<_>>();
+    entries.sort_by(|(left, _), (right, _)| codec.compare_keys(left, 
right).unwrap());
+    let key_type = DataType::Row(RowType::new(fields.to_vec()));
+    let buf = VecFileWrite::new();
+    let mut writer = BTreeIndexWriter::with_comparator_and_options(
+        Box::new(buf.clone()),
+        256,
+        BlockCompressionType::None,
+        1,
+        false,
+        make_key_comparator(&key_type),
+    );
+    for (key, id) in entries {
+        writer.write(Some(&key), id).await.unwrap();
+    }
+    let result = writer.finish().await.unwrap();
+    let bytes = Bytes::from(buf.to_vec());
+    let size = bytes.len() as u64;
+    BTreeIndexReader::open(
+        Box::new(BytesFileRead(bytes)),
+        size,
+        &result.meta,
+        make_key_comparator(&key_type),
+    )
+    .await
+    .unwrap()
+}
+
+fn row_matches(fields: &[DataField], tuple: &[Option<Datum>], predicate: 
&Predicate) -> bool {
+    let row = BinaryRow::from_datums(
+        &tuple
+            .iter()
+            .zip(fields)
+            .map(|(value, field)| (value.as_ref(), field.data_type()))
+            .collect::<Vec<_>>(),
+    );
+    crate::spec::eval_row(predicate, &row).unwrap()
+}
+
+async fn assert_prefix_candidates_and_residual(
+    fields: &[DataField],
+    tuples: &[Vec<Option<Datum>>],
+    predicate: &Predicate,
+    prefix: &Predicate,
+    expected_prefixes: usize,
+) {
+    let plan = CompositePlan::plan(fields, predicate).unwrap().unwrap();
+    assert_eq!(plan.bound_columns, 1);
+    assert_eq!(plan.equal_columns, 1);
+    assert_eq!(plan.intervals.len(), expected_prefixes);
+    assert!(!plan.is_point_lookup());
+    assert!(plan
+        .intervals
+        .iter()
+        .all(|interval| interval.point_key.is_none()));
+    let reader = reader_for_tuples(fields, tuples).await;
+    let candidates = reader.query_composite(&plan).await.unwrap();
+    let expected = tuples
+        .iter()
+        .enumerate()
+        .filter(|(_, tuple)| row_matches(fields, tuple, predicate))
+        .map(|(id, _)| id as u64)
+        .collect::<roaring::RoaringTreemap>();
+    let expected_candidates = tuples
+        .iter()
+        .enumerate()
+        .filter(|(_, tuple)| row_matches(fields, tuple, prefix))
+        .map(|(id, _)| id as u64)
+        .collect::<roaring::RoaringTreemap>();
+    assert!(!expected.is_empty());
+    assert!(expected.is_subset(&candidates));
+    assert!(candidates.len() > expected.len());
+    assert_eq!(candidates, expected_candidates);
+    let actual = candidates
+        .iter()
+        .filter(|id| row_matches(fields, &tuples[*id as usize], predicate))
+        .collect::<roaring::RoaringTreemap>();
+    assert_eq!(actual, expected);
+}
+
+#[tokio::test]
+async fn composite_large_in_keeps_every_point_and_null_suffix() {
+    let fields = integer_fields();
+    let b = PredicateBuilder::new(&fields);
+    // An odd multiplier permutes the complete domain; duplicates must not
+    // inflate expansion and the final values must not be silently truncated.
+    let mut literals = (0..4096)
+        .map(|i| Datum::Int((i * 1021) % 4096))
+        .collect::<Vec<_>>();
+    literals.extend((0..1000).map(Datum::Int));
+    let predicate = Predicate::and(vec![
+        b.is_in("k0", literals).unwrap(),
+        b.is_null("k1").unwrap(),
+        b.equal("k2", Datum::Int(7)).unwrap(),
+    ]);
+    let plan = CompositePlan::plan(&fields, &predicate).unwrap().unwrap();
+    assert_eq!(plan.intervals.len(), 4096);
+    assert_eq!(plan.bound_columns, 3);
+    assert_eq!(plan.equal_columns, 3);
+    assert!(plan.is_point_lookup());
+    assert!(plan
+        .intervals
+        .iter()
+        .all(|interval| interval.point_key.is_some()));
+    let mut tuples = (0..4096)
+        .map(|i| vec![Some(Datum::Int(i)), None, Some(Datum::Int(7))])
+        .collect::<Vec<_>>();
+    tuples.extend([
+        vec![
+            Some(Datum::Int(4095)),
+            Some(Datum::Int(0)),
+            Some(Datum::Int(7)),
+        ],
+        vec![Some(Datum::Int(4095)), None, Some(Datum::Int(8))],
+        vec![Some(Datum::Int(5000)), None, Some(Datum::Int(7))],
+    ]);
+    let reader = reader_for_tuples(&fields, &tuples).await;
+    let actual = reader.query_composite(&plan).await.unwrap();
+    assert_eq!(actual, (0..4096).collect::<roaring::RoaringTreemap>());
+    assert!(actual.contains(4095));
+}
+
+#[tokio::test]
+async fn composite_cartesian_limit_keeps_left_prefix_and_residual() {
+    let fields = integer_fields();
+    let b = PredicateBuilder::new(&fields);
+    let prefix = b
+        .is_in("k0", (0..17).rev().map(Datum::Int).collect())
+        .unwrap();
+    let predicate = Predicate::and(vec![
+        prefix.clone(),
+        b.is_in("k1", (0..17).map(Datum::Int).collect()).unwrap(),
+        b.equal("k2", Datum::Int(5)).unwrap(),
+    ]);
+    let mut tuples = Vec::new();
+    for first in -1..19 {
+        for second in -1..19 {
+            for suffix in [4, 5] {
+                tuples.push(vec![
+                    Some(Datum::Int(first)),
+                    Some(Datum::Int(second)),
+                    Some(Datum::Int(suffix)),
+                ]);
+            }
+        }
+    }
+    assert_prefix_candidates_and_residual(&fields, &tuples, &predicate, 
&prefix, 17).await;
+}
+
+#[tokio::test]
+async fn composite_oversized_prefix_is_preserved_before_next_in() {
+    let fields = integer_fields();
+    let b = PredicateBuilder::new(&fields);
+    let prefix = b
+        .is_in("k0", (0..513).rev().map(Datum::Int).collect())
+        .unwrap();
+    let predicate = Predicate::and(vec![
+        prefix.clone(),
+        b.is_in("k1", vec![Datum::Int(0), Datum::Int(1)]).unwrap(),
+        b.equal("k2", Datum::Int(5)).unwrap(),
+    ]);
+    let mut tuples = Vec::new();
+    for first in 0..514 {
+        for second in [0, 1, 2] {
+            for suffix in [4, 5] {
+                tuples.push(vec![
+                    Some(Datum::Int(first)),
+                    Some(Datum::Int(second)),
+                    Some(Datum::Int(suffix)),
+                ]);
+            }
+        }
+    }
+    assert_prefix_candidates_and_residual(&fields, &tuples, &predicate, 
&prefix, 513).await;
+}
+
+#[tokio::test]
+async fn composite_large_in_intersects_other_constraints() {
+    let fields = integer_fields();
+    let b = PredicateBuilder::new(&fields);
+    let first = b
+        .is_in("k0", (0..4096).rev().map(Datum::Int).collect())
+        .unwrap();
+    let mut tuples = (0..8192)
+        .map(|i| {
+            vec![
+                Some(Datum::Int(i)),
+                Some(Datum::Int(1)),
+                Some(Datum::Int(1)),
+            ]
+        })
+        .collect::<Vec<_>>();
+    tuples.push(vec![None, Some(Datum::Int(1)), Some(Datum::Int(1))]);
+    let reader = reader_for_tuples(&fields, &tuples).await;
+    for (constraint, expected) in [
+        (
+            Predicate::and(vec![
+                b.greater_or_equal("k0", Datum::Int(4089)).unwrap(),
+                b.less_than("k0", Datum::Int(4096)).unwrap(),
+            ]),
+            4089..4096,
+        ),
+        (
+            b.is_in("k0", (2048..6144).map(Datum::Int).collect())
+                .unwrap(),
+            2048..4096,
+        ),
+    ] {
+        let predicate = Predicate::and(vec![
+            first.clone(),
+            constraint,
+            b.equal("k1", Datum::Int(1)).unwrap(),
+            b.equal("k2", Datum::Int(1)).unwrap(),
+        ]);
+        let plan = CompositePlan::plan(&fields, &predicate).unwrap().unwrap();
+        assert!(plan.is_point_lookup());
+        assert_eq!(
+            plan.intervals.len(),
+            (expected.end - expected.start) as usize
+        );
+        let actual = reader.query_composite(&plan).await.unwrap();
+        assert_eq!(actual, expected.collect::<roaring::RoaringTreemap>());
+    }
+}
+
+#[tokio::test]
+async fn composite_point_domain_identity_matches_encoded_keys() {
+    use crate::spec::{DecimalType, DoubleType, FloatType};
+
+    let fields = vec![
+        DataField::new(0, "f".into(), DataType::Float(FloatType::new())),
+        DataField::new(1, "d".into(), DataType::Double(DoubleType::new())),
+        DataField::new(
+            2,
+            "n".into(),
+            DataType::Decimal(DecimalType::new(18, 2).unwrap()),
+        ),
+    ];
+    let b = PredicateBuilder::new(&fields);
+    let decimal = |unscaled, scale| Datum::Decimal {
+        unscaled,
+        precision: 18,
+        scale,
+    };
+    let predicate = Predicate::and(vec![
+        b.is_in(
+            "f",
+            vec![
+                Datum::Float(f32::NAN),
+                Datum::Float(f32::from_bits(0xffc00001)),
+                Datum::Float(-0.0),
+                Datum::Float(0.0),
+            ],
+        )
+        .unwrap(),
+        b.is_in(
+            "d",
+            vec![
+                Datum::Double(f64::NAN),
+                Datum::Double(f64::from_bits(0xfff8000000000001)),
+                Datum::Double(-0.0),
+                Datum::Double(0.0),
+            ],
+        )
+        .unwrap(),
+        b.is_in("n", vec![decimal(1, 0), decimal(100, 2), decimal(1000, 3)])
+            .unwrap(),
+    ]);
+    let plan = CompositePlan::plan(&fields, &predicate).unwrap().unwrap();
+    assert!(plan.is_point_lookup());
+    // Each floating domain has one canonical NaN and two distinct zeros;
+    // all three decimal literals normalize to the same indexed value.
+    assert_eq!(plan.intervals.len(), 9);
+    let mut tuples = Vec::new();
+    for float in [f32::NAN, -0.0, 0.0] {
+        for double in [f64::NAN, -0.0, 0.0] {
+            tuples.push(vec![
+                Some(Datum::Float(float)),
+                Some(Datum::Double(double)),
+                Some(decimal(100, 2)),
+            ]);
+        }
+    }
+    let reader = reader_for_tuples(&fields, &tuples).await;
+    assert_eq!(
+        reader.query_composite(&plan).await.unwrap(),
+        (0..9).collect::<roaring::RoaringTreemap>()
+    );
+    let narrowed = Predicate::and(vec![
+        predicate,
+        b.is_in(
+            "f",
+            vec![Datum::Float(0.0), Datum::Float(f32::from_bits(0xffc00001))],
+        )
+        .unwrap(),
+        b.is_in("n", vec![decimal(1000, 3)]).unwrap(),
+    ]);
+    let plan = CompositePlan::plan(&fields, &narrowed).unwrap().unwrap();
+    assert_eq!(
+        reader.query_composite(&plan).await.unwrap(),
+        [0, 1, 2, 6, 7, 8]
+            .into_iter()
+            .collect::<roaring::RoaringTreemap>()
+    );
+}
+
+#[tokio::test]
+async fn composite_lookup_batches_reads_without_block_cache() {
+    use crate::io::FileRead;
+    use std::collections::HashMap;
+    use std::sync::{Arc, Mutex};
+
+    struct CountedRead {
+        bytes: Bytes,
+        reads: Arc<Mutex<HashMap<(u64, u64), usize>>>,
+    }
+
+    #[async_trait::async_trait]
+    impl FileRead for CountedRead {
+        async fn read(&self, range: std::ops::Range<u64>) -> 
crate::Result<Bytes> {
+            *self
+                .reads
+                .lock()
+                .unwrap()
+                .entry((range.start, range.end))
+                .or_default() += 1;
+            Ok(self.bytes.slice(range.start as usize..range.end as usize))
+        }
+    }
+
+    let fields = integer_fields();
+    let b = PredicateBuilder::new(&fields);
+    // Cover complete points, prefixes in one block, and shuffled prefixes
+    // spanning many blocks, with some intervals spanning blocks themselves.
+    for (complete, block_size) in [(true, 1_000_000), (false, 1_000_000), 
(false, 512)] {
+        let predicate = Predicate::and(vec![
+            b.is_in(
+                "k0",
+                (0..4096).map(|i| Datum::Int((i * 2053) % 4096)).collect(),
+            )
+            .unwrap(),
+            b.is_null("k1").unwrap(),
+            if complete {
+                b.equal("k2", Datum::Int(7)).unwrap()
+            } else {
+                b.between("k2", Datum::Int(20), Datum::Int(70)).unwrap()
+            },
+        ]);
+        let plan = CompositePlan::plan(&fields, &predicate).unwrap().unwrap();
+        let codec = CompositeKeyCodec::new(&fields);
+        let key_type = DataType::Row(RowType::new(fields.clone()));
+        let buf = VecFileWrite::new();
+        let mut writer = BTreeIndexWriter::with_comparator_and_options(
+            Box::new(buf.clone()),
+            block_size,
+            BlockCompressionType::None,
+            1,
+            false,
+            make_key_comparator(&key_type),
+        );
+        let mut expected = roaring::RoaringTreemap::new();
+        let mut row_id = 0;
+        for id in 0..4096 {
+            let suffixes = if id % 256 == 0 { 0..101 } else { 7..8 };
+            for suffix in suffixes {
+                let key = codec
+                    .serialize(&[Some(Datum::Int(id)), None, 
Some(Datum::Int(suffix))])
+                    .unwrap();
+                writer.write(Some(&key), row_id).await.unwrap();
+                if (complete && suffix == 7) || (!complete && 
(20..=70).contains(&suffix)) {
+                    expected.insert(row_id as u64);
+                }
+                row_id += 1;
+            }
+        }
+        let result = writer.finish().await.unwrap();
+        let bytes = Bytes::from(buf.to_vec());
+        let size = bytes.len() as u64;
+        let reads = Arc::new(Mutex::new(HashMap::new()));
+        let reader = BTreeIndexReader::open_with_data_block_cache(
+            Box::new(CountedRead {
+                bytes,
+                reads: Arc::clone(&reads),
+            }),
+            size,
+            &result.meta,
+            make_key_comparator(&key_type),
+            Arc::new(BTreeDataBlockCache::new(0)),
+            Arc::from("memory:/composite-batch.btree"),
+        )
+        .await
+        .unwrap();
+        reads.lock().unwrap().clear();
+        assert_eq!(reader.query_composite(&plan).await.unwrap(), expected);
+        let reads = reads.lock().unwrap();
+        assert!(!reads.is_empty());
+        assert!(
+            reads.values().all(|count| *count == 1),
+            "a target block was read repeatedly: complete={complete}, 
block_size={block_size}"
+        );
+        if block_size == 1_000_000 {
+            assert_eq!(reads.len(), 1);
+        } else {
+            assert!(reads.len() > 1);
+        }
+    }
+}
+
 fn predicates(fields: &[DataField]) -> Vec<Predicate> {
     let b = PredicateBuilder::new(fields);
     let mut predicates = vec![
diff --git a/crates/paimon/src/btree/reader.rs 
b/crates/paimon/src/btree/reader.rs
index 44fe15b6..0cf4f009 100644
--- a/crates/paimon/src/btree/reader.rs
+++ b/crates/paimon/src/btree/reader.rs
@@ -226,12 +226,29 @@ impl<F: Fn(&[u8], &[u8]) -> crate::Result<Ordering>> 
BTreeIndexReader<F> {
         &self,
         plan: &super::CompositePlan,
     ) -> io::Result<RoaringTreemap> {
+        if plan.is_point_lookup() {
+            let keys = plan
+                .intervals
+                .iter()
+                .map(|interval| interval.point_key.as_deref().expect("complete 
tuple point"))
+                .collect::<Vec<_>>();
+            return match keys.as_slice() {
+                [key] => self.query_equal(key).await,
+                _ => self.query_in(&keys).await,
+            };
+        }
+        // The planner emits disjoint prefix intervals in input literal order.
+        // Sort them so scans only move forwards, retaining their last block
+        // across intervals even when the shared block cache is disabled.
+        let mut intervals = plan.intervals.iter().collect::<Vec<_>>();
+        try_sort_by(&mut intervals, |left, right| {
+            left.lower
+                .compare(&right.lower, &plan.codec)
+                .map_err(key_comparison_io_error)
+        })?;
+        let mut last_block: Option<(BlockHandle, Arc<BlockReader>)> = None;
         let mut result = RoaringTreemap::new();
-        for interval in &plan.intervals {
-            if let Some(key) = &interval.point_key {
-                result |= self.query_equal(key).await?;
-                continue;
-            }
+        for interval in intervals {
             let cmp = |key: &[u8], _: &[u8]| {
                 interval
                     .lower
@@ -242,7 +259,18 @@ impl<F: Fn(&[u8], &[u8]) -> crate::Result<Ordering>> 
BTreeIndexReader<F> {
             let mut first = true;
             while let Some((_, handle_bytes)) = index_iter.next() {
                 let handle = BlockHandle::decode(handle_bytes)?;
-                let block = self.read_data_block(&handle).await?;
+                let block = match &last_block {
+                    Some((previous, block))
+                        if previous.offset == handle.offset && previous.size 
== handle.size =>
+                    {
+                        Arc::clone(block)
+                    }
+                    _ => {
+                        let block = self.read_data_block(&handle).await?;
+                        last_block = Some((handle, Arc::clone(&block)));
+                        block
+                    }
+                };
                 let mut offset = if first {
                     first = false;
                     block.seek_and_iter(&[], &cmp)?.1.offset
diff --git a/crates/paimon/src/table/global_index_freshness.rs 
b/crates/paimon/src/table/global_index_freshness.rs
new file mode 100644
index 00000000..3430374d
--- /dev/null
+++ b/crates/paimon/src/table/global_index_freshness.rs
@@ -0,0 +1,735 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Verify that active global indexes still describe the indexed column values.
+
+use std::collections::{HashMap, HashSet};
+
+use super::global_index_types::normalize_queryable_global_index_type;
+use super::key_normalization::canonical_partition_key;
+use super::{DataSplit, Table};
+use crate::spec::{
+    BinaryRow, DataEvolutionIndexSourceMeta, DataField, DataFileMeta,
+    GlobalIndexColumnUpdateAction, IndexManifest, Snapshot,
+};
+
+/// Under IGNORE, prove that indexed columns have not changed since the index's
+/// source snapshot. Other current update policies bypass this check.
+/// `partitions` contains canonical logical partition keys.
+pub(super) async fn indexed_columns_unchanged(
+    table: &Table,
+    snapshot: &Snapshot,
+    columns: &[String],
+    partitions: &HashSet<Vec<u8>>,
+) -> crate::Result<bool> {
+    if table
+        .schema()
+        .core_options()
+        .global_index_column_update_action()?
+        != GlobalIndexColumnUpdateAction::Ignore
+    {
+        return Ok(true);
+    }
+    let Some(manifest) = snapshot.index_manifest() else {
+        return Ok(true);
+    };
+    let path = table.snapshot_manager().manifest_path(manifest);
+    let entries = IndexManifest::read(table.file_io(), &path).await?;
+    let fields = table.schema().fields();
+    let partition_fields = table.schema().partition_fields();
+    let mut indexes = Vec::new();
+    for entry in &entries {
+        if entry.kind != crate::spec::FileKind::Add
+            || 
normalize_queryable_global_index_type(&entry.index_file.index_type).is_none()
+        {
+            continue;
+        }
+        let Some(meta) = &entry.index_file.global_index_meta else {
+            continue;
+        };
+        let index_partition = if partition_fields.is_empty() {
+            Vec::new()
+        } else {
+            canonical_partition_key(
+                &BinaryRow::from_serialized_bytes(&entry.partition)?,
+                &partition_fields,
+            )?
+        };
+        if !partition_fields.is_empty() && 
!partitions.contains(&index_partition) {
+            continue;
+        }
+        if meta.row_range_start < 0 || meta.row_range_end < 
meta.row_range_start {
+            return Ok(false);
+        }
+        let keys = fields
+            .iter()
+            .filter(|field| {
+                columns.iter().any(|column| column == field.name())
+                    && (field.id() == meta.index_field_id
+                        || meta
+                            .extra_field_ids
+                            .as_ref()
+                            .is_some_and(|ids| ids.contains(&field.id())))
+            })
+            .collect::<Vec<_>>();
+        if keys.is_empty() {
+            continue;
+        }
+        let Some(source_id) = meta
+            .source_meta
+            .as_deref()
+            .and_then(|bytes| 
DataEvolutionIndexSourceMeta::deserialize(bytes).ok())
+            .map(|meta| meta.scan_snapshot_id())
+            .filter(|id| *id <= snapshot.id())
+        else {
+            return Ok(false);
+        };
+        indexes.push((entry, index_partition, keys, source_id));
+    }
+    if indexes.is_empty() {
+        return Ok(true);
+    }
+    // Inspect every active column file before key predicates or indexes can
+    // prune away evidence of historical updates. This reads metadata only.
+    let unindexed = table
+        .copy_with_pinned_snapshot(Some(snapshot))
+        .copy_with_options(HashMap::from([(
+            "global-index.enabled".into(),
+            "false".into(),
+        )]));
+    let plan = unindexed.new_read_builder().new_scan().plan_all().await?;
+    let index_groups = indexes
+        .iter()
+        .map(|(entry, partition, _, _)| (partition.clone(), entry.bucket))
+        .collect();
+    let files_by_group = file_range_lookups(plan.splits(), &partition_fields, 
&index_groups)?;
+    for (entry, index_partition, keys, source_id) in indexes {
+        let meta = entry.index_file.global_index_meta.as_ref().unwrap();
+        let Some(files) = files_by_group.get(&(index_partition, entry.bucket)) 
else {
+            continue;
+        };
+        if !files.all_overlapping_unchanged(
+            meta.row_range_start,
+            meta.row_range_end,
+            table.schema(),
+            &keys,
+            source_id,
+        ) {
+            return Ok(false);
+        }
+    }
+    Ok(true)
+}
+
+fn file_range_lookups<'a>(
+    splits: &'a [DataSplit],
+    partition_fields: &[DataField],
+    index_groups: &HashSet<(Vec<u8>, i32)>,
+) -> crate::Result<HashMap<(Vec<u8>, i32), FileRangeLookup<'a>>> {
+    let buckets = index_groups
+        .iter()
+        .map(|(_, bucket)| *bucket)
+        .collect::<HashSet<_>>();
+    let mut groups: HashMap<_, Vec<_>> = HashMap::new();
+    for split in splits {
+        if !buckets.contains(&split.bucket()) {
+            continue;
+        }
+        let partition = if partition_fields.is_empty() {
+            Vec::new()
+        } else {
+            canonical_partition_key(split.partition(), partition_fields)?
+        };
+        if !index_groups.contains(&(partition.clone(), split.bucket())) {
+            continue;
+        }
+        groups
+            .entry((partition, split.bucket()))
+            .or_default()
+            .extend(split.data_files());
+    }
+    Ok(groups
+        .into_iter()
+        .map(|(group, files)| (group, FileRangeLookup::new(files)))
+        .collect())
+}
+
+struct FileRangeLookup<'a> {
+    ranges: Option<Box<FileRangeTree<'a>>>,
+    unknown_ranges: Vec<&'a DataFileMeta>,
+}
+
+impl<'a> FileRangeLookup<'a> {
+    fn new(files: Vec<&'a DataFileMeta>) -> Self {
+        let mut ranges = Vec::new();
+        let mut unknown_ranges = Vec::new();
+        for file in files {
+            if let Some((start, end)) = file.row_id_range() {
+                ranges.push(FileRange { start, end, file });
+            } else {
+                unknown_ranges.push(file);
+            }
+        }
+        ranges.sort_unstable_by_key(|range| (range.start, range.end));
+        Self {
+            ranges: FileRangeTree::from_sorted(ranges),
+            unknown_ranges,
+        }
+    }
+
+    fn all_overlapping_unchanged(
+        &self,
+        start: i64,
+        end: i64,
+        schema: &crate::spec::TableSchema,
+        keys: &[&DataField],
+        source_id: i64,
+    ) -> bool {
+        // A missing range cannot prove disjointness. Retain the existing
+        // column-level check, which can still accept a payload-only file.
+        self.unknown_ranges.iter().all(|file| {
+            file.schema_id == schema.id()
+                && file_columns_unchanged(file, schema, keys, source_id, false)
+        }) && self.ranges.as_ref().is_none_or(|ranges| {
+            ranges.all_overlapping(start, end, &mut |file| {
+                file.schema_id == schema.id()
+                    && file_columns_unchanged(file, schema, keys, source_id, 
true)
+            })
+        })
+    }
+}
+
+struct FileRange<'a> {
+    start: i64,
+    end: i64,
+    file: &'a DataFileMeta,
+}
+
+/// A centered interval tree reports overlaps in O(log(files) + overlaps).
+/// A sorted prefix alone would rescan every preceding file when even one
+/// long-lived column file spans many disjoint index shards.
+struct FileRangeTree<'a> {
+    center: i64,
+    by_start: Vec<FileRange<'a>>,
+    by_end: Vec<usize>,
+    left: Option<Box<Self>>,
+    right: Option<Box<Self>>,
+    #[cfg(test)]
+    inspected: std::cell::Cell<usize>,
+}
+
+impl<'a> FileRangeTree<'a> {
+    fn from_sorted(ranges: Vec<FileRange<'a>>) -> Option<Box<Self>> {
+        if ranges.is_empty() {
+            return None;
+        }
+        let center = ranges[ranges.len() / 2].start;
+        let (mut left, mut right, mut by_start) = (Vec::new(), Vec::new(), 
Vec::new());
+        for range in ranges {
+            if range.end < center {
+                left.push(range);
+            } else if range.start > center {
+                right.push(range);
+            } else {
+                by_start.push(range);
+            }
+        }
+        let mut by_end = (0..by_start.len()).collect::<Vec<_>>();
+        by_end.sort_unstable_by_key(|index| 
std::cmp::Reverse(by_start[*index].end));
+        Some(Box::new(Self {
+            center,
+            by_start,
+            by_end,
+            left: Self::from_sorted(left),
+            right: Self::from_sorted(right),
+            #[cfg(test)]
+            inspected: std::cell::Cell::new(0),
+        }))
+    }
+
+    fn all_overlapping(
+        &self,
+        start: i64,
+        end: i64,
+        check: &mut impl FnMut(&DataFileMeta) -> bool,
+    ) -> bool {
+        if end < self.center {
+            for range in &self.by_start {
+                #[cfg(test)]
+                self.inspected.set(self.inspected.get() + 1);
+                if range.start > end {
+                    break;
+                }
+                if !check(range.file) {
+                    return false;
+                }
+            }
+        } else if start > self.center {
+            for index in &self.by_end {
+                #[cfg(test)]
+                self.inspected.set(self.inspected.get() + 1);
+                let range = &self.by_start[*index];
+                if range.end < start {
+                    break;
+                }
+                if !check(range.file) {
+                    return false;
+                }
+            }
+        } else {
+            for range in &self.by_start {
+                #[cfg(test)]
+                self.inspected.set(self.inspected.get() + 1);
+                if !check(range.file) {
+                    return false;
+                }
+            }
+        }
+        (start > self.center
+            || self
+                .left
+                .as_ref()
+                .is_none_or(|left| left.all_overlapping(start, end, check)))
+            && (end < self.center
+                || self
+                    .right
+                    .as_ref()
+                    .is_none_or(|right| right.all_overlapping(start, end, 
check)))
+    }
+
+    #[cfg(test)]
+    fn inspections(&self) -> usize {
+        self.inspected.get()
+            + self.left.as_ref().map_or(0, |tree| tree.inspections())
+            + self.right.as_ref().map_or(0, |tree| tree.inspections())
+    }
+}
+
+fn file_columns_unchanged(
+    file: &DataFileMeta,
+    schema: &crate::spec::TableSchema,
+    keys: &[&DataField],
+    source_id: i64,
+    has_row_range: bool,
+) -> bool {
+    // Java aligns column sequences with the physical schema: omitted write
+    // columns and row-tracking metadata do not occupy a sequence slot.
+    let fields = schema.data_file_fields(file.write_cols.as_deref());
+    if fields
+        .iter()
+        .map(DataField::id)
+        .collect::<HashSet<_>>()
+        .len()
+        != fields.len()
+        || file.write_cols.as_ref().is_some_and(|columns| {
+            columns.iter().any(|name| {
+                !crate::spec::is_row_tracking_column(name)
+                    && name != crate::spec::VALUE_KIND_FIELD_NAME
+                    && !schema.fields().iter().any(|field| field.name() == 
name)
+            })
+        })
+    {
+        return false;
+    }
+    keys.iter().all(|key| {
+        let Some(position) = fields.iter().position(|field| field.id() == 
key.id()) else {
+            return true;
+        };
+        let sequence = file
+            .column_max_sequence_numbers
+            .as_ref()
+            .filter(|sequences| sequences.len() == fields.len())
+            .and_then(|sequences| sequences.get(position))
+            .copied()
+            .filter(|sequence| *sequence > 0 && *sequence <= 
file.max_sequence_number)
+            .unwrap_or(file.max_sequence_number);
+        has_row_range && sequence > 0 && sequence <= source_id
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::stats::BinaryTableStats;
+    use crate::spec::{
+        BinaryRowBuilder, DataFileMeta, DataType as PaimonType, IntType, 
Schema as PaimonSchema,
+        TableSchema,
+    };
+
+    fn test_schema() -> TableSchema {
+        let schema = PaimonSchema::builder()
+            .column("key", PaimonType::Int(IntType::new()))
+            .column("value", PaimonType::Int(IntType::new()))
+            .build()
+            .unwrap();
+        TableSchema::new(0, &schema)
+    }
+
+    fn test_file(name: &str, start: i64, end: i64) -> DataFileMeta {
+        DataFileMeta {
+            file_name: name.into(),
+            file_size: 128,
+            row_count: end - start + 1,
+            min_key: vec![],
+            max_key: vec![],
+            key_stats: BinaryTableStats::empty(),
+            value_stats: BinaryTableStats::empty(),
+            min_sequence_number: 1,
+            max_sequence_number: 1,
+            schema_id: 0,
+            level: 0,
+            extra_files: vec![],
+            creation_time: None,
+            delete_row_count: None,
+            embedded_index: None,
+            first_row_id: Some(start),
+            write_cols: None,
+            external_path: None,
+            file_source: None,
+            value_stats_cols: None,
+            column_max_sequence_numbers: None,
+        }
+    }
+
+    #[test]
+    fn file_range_lookup_preserves_inclusive_nested_and_duplicate_overlaps() {
+        let intervals = [
+            (20, 29),
+            (0, 100),
+            (10, 19),
+            (10, 19),
+            (5, 9),
+            (30, 30),
+            (0, 0),
+            (20, 20),
+            (29, 40),
+        ];
+        let files = intervals
+            .iter()
+            .enumerate()
+            .map(|(id, (start, end))| test_file(&id.to_string(), *start, *end))
+            .collect::<Vec<_>>();
+        let lookup = FileRangeLookup::new(files.iter().collect());
+        let tree = lookup.ranges.as_ref().unwrap();
+        for (start, end) in [
+            (0, 0),
+            (4, 5),
+            (9, 10),
+            (19, 20),
+            (29, 30),
+            (31, 39),
+            (101, 102),
+            (i64::MIN, -1),
+        ] {
+            let mut expected = files
+                .iter()
+                .filter(|file| {
+                    let (file_start, file_end) = file.row_id_range().unwrap();
+                    file_start <= end && file_end >= start
+                })
+                .map(|file| file.file_name.clone())
+                .collect::<Vec<_>>();
+            let mut actual = Vec::new();
+            assert!(tree.all_overlapping(start, end, &mut |file| {
+                actual.push(file.file_name.clone());
+                true
+            }));
+            actual.sort();
+            expected.sort();
+            assert_eq!(actual, expected, "query=[{start}, {end}]");
+            if !expected.is_empty() {
+                let mut checks = 0;
+                assert!(!tree.all_overlapping(start, end, &mut |_| {
+                    checks += 1;
+                    false
+                }));
+                assert_eq!(checks, 1, "stop at the first stale overlap");
+            }
+        }
+        assert!(FileRangeLookup::new(Vec::new()).ranges.is_none());
+    }
+
+    #[test]
+    fn file_range_lookup_checks_only_relevant_columns_ranges_and_sources() {
+        let schema = test_schema();
+        let key = &schema.fields()[0];
+        let value = &schema.fields()[1];
+        let mut updated = test_file("updated", 10, 19);
+        updated.max_sequence_number = 3;
+        updated.column_max_sequence_numbers = Some(vec![1, 3]);
+        let mut malformed = test_file("malformed", 30, 39);
+        malformed.schema_id = 99;
+        let lookup = FileRangeLookup::new(vec![&updated, &malformed]);
+        assert!(lookup.all_overlapping_unchanged(0, 9, &schema, &[key], 1));
+        assert!(lookup.all_overlapping_unchanged(10, 19, &schema, &[key], 1));
+        assert!(!lookup.all_overlapping_unchanged(10, 19, &schema, &[value], 
2));
+        assert!(lookup.all_overlapping_unchanged(10, 19, &schema, &[value], 
3));
+        assert!(!lookup.all_overlapping_unchanged(29, 30, &schema, &[key], 3));
+
+        let mut unknown = test_file("unknown", 0, 9);
+        unknown.first_row_id = None;
+        unknown.write_cols = Some(vec!["value".into()]);
+        unknown.max_sequence_number = 3;
+        assert!(
+            FileRangeLookup::new(vec![&unknown]).all_overlapping_unchanged(
+                100,
+                109,
+                &schema,
+                &[key],
+                1
+            )
+        );
+        assert!(
+            !FileRangeLookup::new(vec![&unknown]).all_overlapping_unchanged(
+                100,
+                109,
+                &schema,
+                &[value],
+                3
+            )
+        );
+        unknown.write_cols = None;
+        assert!(
+            !FileRangeLookup::new(vec![&unknown]).all_overlapping_unchanged(
+                100,
+                109,
+                &schema,
+                &[key],
+                3
+            )
+        );
+        for (first_row_id, row_count) in [(Some(i64::MAX), 2), (Some(0), 0)] {
+            unknown.first_row_id = first_row_id;
+            unknown.row_count = row_count;
+            assert!(
+                
!FileRangeLookup::new(vec![&unknown]).all_overlapping_unchanged(
+                    100,
+                    109,
+                    &schema,
+                    &[key],
+                    3
+                )
+            );
+        }
+    }
+
+    #[test]
+    fn file_range_lookup_keeps_partition_bucket_groups_separate() {
+        let field = DataField::new(0, "part".into(), 
PaimonType::Int(IntType::new()));
+        let partition = |value| {
+            let mut builder = BinaryRowBuilder::new(1);
+            builder.write_int(0, value);
+            builder.build()
+        };
+        let split = |part, bucket, file| {
+            DataSplit::builder()
+                .with_snapshot(1)
+                .with_partition(partition(part))
+                .with_bucket(bucket)
+                
.with_bucket_path(format!("memory:/part={part}/bucket-{bucket}"))
+                .with_data_files(vec![file])
+                .build()
+                .unwrap()
+        };
+        let good = test_file("good", 0, 9);
+        let mut unknown = test_file("unknown", 0, 9);
+        unknown.first_row_id = None;
+        let splits = vec![
+            split(1, 0, good.clone()),
+            split(1, 0, good),
+            split(1, 1, unknown.clone()),
+            split(2, 0, unknown),
+        ];
+        let index_groups = HashSet::from([
+            (partition(1).to_serialized_bytes(), 0),
+            (partition(1).to_serialized_bytes(), 1),
+            (partition(2).to_serialized_bytes(), 0),
+        ]);
+        let groups = file_range_lookups(&splits, &[field], 
&index_groups).unwrap();
+        let schema = test_schema();
+        let keys = [&schema.fields()[0]];
+        assert_eq!(groups.len(), 3);
+        assert!(groups[&(partition(1).to_serialized_bytes(), 0)]
+            .all_overlapping_unchanged(0, 9, &schema, &keys, 1));
+        for group in [
+            (partition(1).to_serialized_bytes(), 1),
+            (partition(2).to_serialized_bytes(), 0),
+        ] {
+            assert!(!groups[&group].all_overlapping_unchanged(0, 9, &schema, 
&keys, 1));
+        }
+        let wanted = HashSet::from([(partition(1).to_serialized_bytes(), 0)]);
+        let groups = file_range_lookups(
+            &splits,
+            &[DataField::new(
+                0,
+                "part".into(),
+                PaimonType::Int(IntType::new()),
+            )],
+            &wanted,
+        )
+        .unwrap();
+        assert_eq!(groups.len(), 1);
+        assert!(groups[&(partition(1).to_serialized_bytes(), 0)]
+            .all_overlapping_unchanged(0, 9, &schema, &keys, 1));
+    }
+
+    #[test]
+    fn file_range_lookup_avoids_quadratic_work_with_one_covering_file() {
+        const SHARDS: usize = 5_000;
+        let mut files = (0..SHARDS)
+            .rev()
+            .map(|id| test_file(&id.to_string(), (id * 4) as i64, (id * 4 + 1) 
as i64))
+            .collect::<Vec<_>>();
+        files.push(test_file("covering", 0, (SHARDS * 4) as i64));
+        let lookup = FileRangeLookup::new(files.iter().collect());
+        let tree = lookup.ranges.as_ref().unwrap();
+        let schema = test_schema();
+        let mut overlaps = 0;
+        for id in 0..SHARDS {
+            let start = (id * 4) as i64;
+            assert!(lookup.all_overlapping_unchanged(
+                start,
+                start + 1,
+                &schema,
+                &[&schema.fields()[0]],
+                1,
+            ));
+            assert!(tree.all_overlapping(start, start + 1, &mut |_| {
+                overlaps += 1;
+                true
+            }));
+        }
+        assert_eq!(overlaps, SHARDS * 2);
+        assert!(
+            tree.inspections() < SHARDS * 2 * 32,
+            "{} range inspections for {SHARDS} shards must stay below 
quadratic work",
+            tree.inspections()
+        );
+        // Many ranges crossing one center also need bounded scans at either
+        // edge; checking all crossing ranges would reintroduce quadratic work.
+        for left_edge in [true, false] {
+            let files = (0..SHARDS)
+                .map(|id| {
+                    let (start, end) = if left_edge {
+                        ((id * 4) as i64, (SHARDS * 4) as i64)
+                    } else {
+                        (0, (id * 4 + 1) as i64)
+                    };
+                    test_file(&id.to_string(), start, end)
+                })
+                .collect::<Vec<_>>();
+            let lookup = FileRangeLookup::new(files.iter().collect());
+            let tree = lookup.ranges.as_ref().unwrap();
+            let query = if left_edge {
+                0
+            } else {
+                (SHARDS * 4 - 3) as i64
+            };
+            let mut overlaps = 0;
+            for _ in 0..SHARDS {
+                assert!(tree.all_overlapping(query, query, &mut |_| {
+                    overlaps += 1;
+                    true
+                }));
+            }
+            assert_eq!(overlaps, SHARDS);
+            assert!(tree.inspections() <= SHARDS * 2, "left_edge={left_edge}");
+        }
+    }
+
+    #[test]
+    fn indexed_columns_use_compacted_column_sequences() {
+        let schema = PaimonSchema::builder()
+            .column("key", PaimonType::Int(IntType::new()))
+            .column("value", PaimonType::Int(IntType::new()))
+            .column("keep", PaimonType::Int(IntType::new()))
+            .build()
+            .unwrap();
+        let schema = TableSchema::new(0, &schema);
+        let mut file = DataFileMeta {
+            file_name: "data.parquet".into(),
+            file_size: 128,
+            row_count: 3,
+            min_key: vec![],
+            max_key: vec![],
+            key_stats: BinaryTableStats::empty(),
+            value_stats: BinaryTableStats::empty(),
+            min_sequence_number: 1,
+            max_sequence_number: 1,
+            schema_id: 0,
+            level: 0,
+            extra_files: vec![],
+            creation_time: None,
+            delete_row_count: None,
+            embedded_index: None,
+            first_row_id: Some(0),
+            write_cols: None,
+            external_path: None,
+            file_source: None,
+            value_stats_cols: None,
+            column_max_sequence_numbers: None,
+        };
+        file.write_cols = Some(vec!["key".into(), "value".into()]);
+        file.column_max_sequence_numbers = Some(vec![1, 3]);
+        file.max_sequence_number = 3;
+        file.file_source = Some(1);
+        let key = &schema.fields()[0];
+        assert!(file_columns_unchanged(&file, &schema, &[key], 2, true));
+        file.column_max_sequence_numbers = Some(vec![3, 3]);
+        assert!(!file_columns_unchanged(&file, &schema, &[key], 2, true));
+        for sequences in [None, Some(vec![1]), Some(vec![0, 3]), Some(vec![4, 
3])] {
+            file.column_max_sequence_numbers = sequences;
+            assert!(!file_columns_unchanged(&file, &schema, &[key], 2, true));
+        }
+        file.max_sequence_number = 1;
+        file.column_max_sequence_numbers = None;
+        assert!(file_columns_unchanged(&file, &schema, &[key], 2, true));
+        file.max_sequence_number = 3;
+        file.write_cols = None;
+        file.column_max_sequence_numbers = Some(vec![1, 3, 1]);
+        assert!(file_columns_unchanged(&file, &schema, &[key], 2, true));
+        file.write_cols = Some(vec!["_ROW_ID".into(), "value".into(), 
"key".into()]);
+        file.column_max_sequence_numbers = Some(vec![3, 1]);
+        assert!(file_columns_unchanged(&file, &schema, &[key], 2, true));
+        for columns in [
+            vec!["key".into(), "key".into()],
+            vec!["key".into(), "unknown".into()],
+        ] {
+            file.write_cols = Some(columns);
+            file.column_max_sequence_numbers = Some(vec![1, 1]);
+            assert!(!file_columns_unchanged(&file, &schema, &[key], 2, true));
+        }
+
+        let schema = PaimonSchema::builder()
+            .column("blob", PaimonType::Blob(crate::spec::BlobType::new()))
+            .column("key", PaimonType::Int(IntType::new()))
+            .column("value", PaimonType::Int(IntType::new()))
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true")
+            .option("data-evolution.write-cols-optimization.enabled", "true")
+            .build()
+            .unwrap();
+        let schema = TableSchema::new(0, &schema);
+        let key = &schema.fields()[1];
+        file.write_cols = None;
+        file.column_max_sequence_numbers = Some(vec![1, 3]);
+        assert!(file_columns_unchanged(&file, &schema, &[key], 2, true));
+        file.write_cols = Some(vec!["blob".into()]);
+        file.column_max_sequence_numbers = Some(vec![3]);
+        assert!(file_columns_unchanged(&file, &schema, &[key], 2, true));
+    }
+}
diff --git a/crates/paimon/src/table/global_index_scanner/composite.rs 
b/crates/paimon/src/table/global_index_scanner/composite.rs
index b84f6e31..c9992667 100644
--- a/crates/paimon/src/table/global_index_scanner/composite.rs
+++ b/crates/paimon/src/table/global_index_scanner/composite.rs
@@ -184,7 +184,14 @@ impl GlobalIndexScanner {
                 continue;
             }
             return Ok(Some(CompositeIndexScanResult {
-                key_field_ids: plan.fields.iter().map(|field| 
field.id()).collect(),
+                // Unbound suffix predicates can still use independent scalar
+                // indexes after the composite prefix has been consumed.
+                key_field_ids: plan
+                    .fields
+                    .iter()
+                    .take(plan.bound_columns)
+                    .map(|field| field.id())
+                    .collect(),
                 result: GlobalIndexScanResult {
                     row_ranges: merge_row_ranges(ranges),
                     // Remaining predicates are always retained by the table 
reader.
diff --git a/crates/paimon/src/table/global_index_scanner/tests.rs 
b/crates/paimon/src/table/global_index_scanner/tests.rs
index c00c9967..89773c87 100644
--- a/crates/paimon/src/table/global_index_scanner/tests.rs
+++ b/crates/paimon/src/table/global_index_scanner/tests.rs
@@ -2121,6 +2121,103 @@ async fn 
test_composite_prefers_fully_covering_leading_bitmap() {
     assert_eq!(result.unwrap(), vec![RowRange::new(105, 106)]);
 }
 
+#[tokio::test]
+async fn test_composite_interval_limit_keeps_suffix_scalar_index() {
+    let fields = two_field_schema_fields();
+    let key_type = DataType::Row(crate::spec::RowType::new(fields.clone()));
+    let composite_output = VecFileWrite::new();
+    let mut composite_writer = BTreeIndexWriter::with_comparator(
+        Box::new(composite_output.clone()),
+        256,
+        BlockCompressionType::None,
+        make_key_comparator(&key_type),
+    );
+    let tuples = [[0, 0], [0, 1], [1, 0], [1, 1]];
+    for (id, [first, second]) in tuples.iter().enumerate() {
+        let key = crate::btree::serialize_composite_key(
+            &[Some(Datum::Int(*first)), Some(Datum::Int(*second))],
+            &fields,
+        )
+        .unwrap();
+        composite_writer.write(Some(&key), id as i64).await.unwrap();
+    }
+    let composite_meta = composite_writer.finish().await.unwrap().meta;
+    let scalar_output = VecFileWrite::new();
+    let mut scalar_writer = BTreeIndexWriter::with_comparator(
+        Box::new(scalar_output.clone()),
+        256,
+        BlockCompressionType::None,
+        make_key_comparator(fields[1].data_type()),
+    );
+    for (value, ids) in [(0, [0, 2]), (1, [1, 3])] {
+        for id in ids {
+            scalar_writer
+                .write(Some(&le_int_key(value)), id)
+                .await
+                .unwrap();
+        }
+    }
+    let scalar_meta = scalar_writer.finish().await.unwrap().meta;
+    let tmp = tempfile::tempdir().unwrap();
+    std::fs::create_dir(tmp.path().join("index")).unwrap();
+    let composite_bytes = composite_output.to_vec();
+    let scalar_bytes = scalar_output.to_vec();
+    std::fs::write(tmp.path().join("index/composite.btree"), 
&composite_bytes).unwrap();
+    std::fs::write(tmp.path().join("index/suffix.btree"), 
&scalar_bytes).unwrap();
+    let mut composite = make_global_index_entry("composite.btree", 1, 0, 3, 
&composite_meta);
+    composite.index_file.file_size = composite_bytes.len() as i64;
+    composite
+        .index_file
+        .global_index_meta
+        .as_mut()
+        .unwrap()
+        .extra_field_ids = Some(vec![2]);
+    let mut scalar = make_global_index_entry("suffix.btree", 2, 0, 3, 
&scalar_meta);
+    scalar.index_file.file_size = scalar_bytes.len() as i64;
+    let entries = [composite, scalar];
+    let io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    let path = format!("file://{}", tmp.path().display());
+    let mut scanner =
+        GlobalIndexScanner::create(&io, &path, 2, i64::MAX, i64::MAX, 
&entries, &fields)
+            .unwrap()
+            .unwrap();
+    let probe = Arc::new(QueryIoProbe::default());
+    scanner.query_io_probe = Some(Arc::clone(&probe));
+    let b = crate::spec::PredicateBuilder::new(&fields);
+    // 17 x 17 stops the tuple plan after the first column. Its unbound suffix
+    // must remain available for the independent scalar index to narrow rows.
+    let predicate = Predicate::and(vec![
+        b.is_in(
+            "id",
+            [0, 1].into_iter().chain(100..115).map(Datum::Int).collect(),
+        )
+        .unwrap(),
+        b.is_in(
+            "value",
+            [1].into_iter().chain(100..116).map(Datum::Int).collect(),
+        )
+        .unwrap(),
+    ]);
+    let expected = tuples
+        .iter()
+        .enumerate()
+        .filter(|(_, tuple)| {
+            let datums = tuple.map(Datum::Int);
+            let row = crate::spec::BinaryRow::from_datums(&[
+                (Some(&datums[0]), fields[0].data_type()),
+                (Some(&datums[1]), fields[1].data_type()),
+            ]);
+            crate::spec::eval_row(&predicate, &row).unwrap()
+        })
+        .map(|(id, _)| id as u64)
+        .collect::<RoaringTreemap>();
+    assert_eq!(expected.iter().collect::<Vec<_>>(), vec![1, 3]);
+    let result = scanner.evaluate(&predicate).await.unwrap().unwrap();
+    assert_eq!(result.row_ranges, bitmap_to_ranges(&expected));
+    assert_eq!(result.evaluated_field_ids, HashSet::from([1, 2]));
+    assert_eq!(probe.predicate_queries.load(TestOrdering::SeqCst), 1);
+}
+
 async fn assert_bitmap_int_fixture(file_name: &str) {
     let data_type = DataType::Int(crate::spec::IntType::new());
     let (file_io, table_path, file_name, meta, _tmp) = 
setup_bitmap_testdata_table(file_name);
diff --git a/crates/paimon/src/table/key_normalization.rs 
b/crates/paimon/src/table/key_normalization.rs
index 01264bb4..8622f868 100644
--- a/crates/paimon/src/table/key_normalization.rs
+++ b/crates/paimon/src/table/key_normalization.rs
@@ -22,12 +22,29 @@ use arrow_schema::DataType;
 
 use super::update_input::{cast_update_value, encoded, CastMode};
 use super::write_batch_normalize::normalize_write_array;
+use crate::spec::{extract_datum, BinaryRow, BinaryRowBuilder, DataField};
 
 mod numeric;
 
 #[cfg(test)]
 mod encoded_tests;
 
+// Java may reserve variable-length storage for NULL timestamps/decimals.
+// Compare logical partitions using one encoding, independent of that padding.
+pub(super) fn canonical_partition_key(
+    row: &BinaryRow,
+    fields: &[DataField],
+) -> crate::Result<Vec<u8>> {
+    let mut builder = BinaryRowBuilder::new(fields.len() as i32);
+    for (index, field) in fields.iter().enumerate() {
+        match extract_datum(row, index, field.data_type())? {
+            Some(value) => builder.write_datum(index, &value, 
field.data_type()),
+            None => builder.set_null_at(index),
+        }
+    }
+    Ok(builder.build().to_serialized_bytes())
+}
+
 /// Unify Arrow representations without changing key identity. Dictionary
 /// indices are batch-local; numeric widths, decimal scales and temporal units
 /// may differ only when conversion is exact. Binary keys compare their bytes.
@@ -147,6 +164,7 @@ pub(super) fn normalize_match_key(
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::spec::{DataType as PaimonType, DecimalType, TimestampType, 
VarCharType};
     use arrow_array::types::Int8Type;
     use arrow_array::{
         Array, BinaryArray, Decimal128Array, DictionaryArray, 
FixedSizeBinaryArray, Float32Array,
@@ -365,4 +383,45 @@ mod tests {
             normalize_match_key(&local, 
&DataType::Timestamp(TimeUnit::Microsecond, None)).is_err()
         );
     }
+
+    #[test]
+    fn partition_identity_ignores_java_null_variable_storage() {
+        let fields = vec![
+            DataField::new(
+                0,
+                "ts".into(),
+                PaimonType::Timestamp(TimestampType::new(6).unwrap()),
+            ),
+            DataField::new(
+                1,
+                "amount".into(),
+                PaimonType::Decimal(DecimalType::new(20, 2).unwrap()),
+            ),
+            DataField::new(
+                2,
+                "label".into(),
+                PaimonType::VarChar(VarCharType::new(100).unwrap()),
+            ),
+        ];
+        let mut builder = BinaryRowBuilder::new(3);
+        builder.set_null_at(0);
+        builder.set_null_at(1);
+        builder.write_string(2, "after-null-fields");
+        let canonical = builder.build();
+        let mut java = canonical.data()[..32].to_vec();
+        // BinaryWriter.writeTimestamp(NULL, 6) reserves 8 bytes;
+        // writeDecimal(NULL, 20) reserves another 16, even though both are 
NULL.
+        java.extend_from_slice(&[0; 24]);
+        java.extend_from_slice(&canonical.data()[32..]);
+        java[8..16].copy_from_slice(&(32_u64 << 32).to_le_bytes());
+        java[16..24].copy_from_slice(&(40_u64 << 32).to_le_bytes());
+        let offset_and_size = 
u64::from_le_bytes(canonical.data()[24..32].try_into().unwrap());
+        java[24..32].copy_from_slice(&(offset_and_size + (24_u64 << 
32)).to_le_bytes());
+        let java = BinaryRow::from_bytes(3, java);
+        assert_ne!(java.to_serialized_bytes(), 
canonical.to_serialized_bytes());
+        assert_eq!(
+            canonical_partition_key(&java, &fields).unwrap(),
+            canonical.to_serialized_bytes()
+        );
+    }
 }
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 0fee5af8..87e89f56 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -80,6 +80,7 @@ mod full_text_search_builder;
 mod global_index_build_builder;
 pub(crate) mod global_index_build_common;
 mod global_index_drop_builder;
+mod global_index_freshness;
 mod global_index_result;
 pub(crate) mod global_index_scanner;
 mod global_index_search_filter;
diff --git a/crates/paimon/src/table/table_upsert.rs 
b/crates/paimon/src/table/table_upsert.rs
index 73d62647..e18521ea 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -18,7 +18,7 @@
 //! Table-level append-only upsert by user-specified keys.
 
 use std::borrow::Cow;
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
 use std::ops::Range;
 use std::sync::Arc;
 
@@ -28,11 +28,14 @@ use arrow_select::{concat::concat_batches, take::take};
 use futures::TryStreamExt;
 use indexmap::IndexMap;
 
+use super::global_index_freshness::indexed_columns_unchanged;
+use super::key_normalization::canonical_partition_key;
 use super::update_input::unique_column_index;
 use super::upsert_key_matcher::UpsertKeyMatcher;
 use super::write_batch_normalize::{normalize_blob_write_field, 
normalize_write_array};
 use crate::spec::{
-    batch_to_serialized_bytes, extract_datum, BinaryRow, BinaryRowBuilder, 
CoreOptions, DataField,
+    batch_to_serialized_bytes, extract_datum_from_arrow, BinaryRow, 
CoreOptions, Predicate,
+    PredicateBuilder,
 };
 use crate::table::source::{DataSplit, Plan};
 use crate::table::{CommitMessage, Table};
@@ -64,19 +67,6 @@ fn selected_rows(batch: &RecordBatch, rows: &[usize]) -> 
crate::Result<RecordBat
         .map_err(|error| invalid(format!("cannot build selected upsert rows: 
{error}")))
 }
 
-// Java may reserve variable-length storage for NULL timestamps/decimals.
-// Compare logical partitions using one encoding, independent of that padding.
-fn partition_key(row: &BinaryRow, fields: &[DataField]) -> 
crate::Result<Vec<u8>> {
-    let mut builder = BinaryRowBuilder::new(fields.len() as i32);
-    for (index, field) in fields.iter().enumerate() {
-        match extract_datum(row, index, field.data_type())? {
-            Some(value) => builder.write_datum(index, &value, 
field.data_type()),
-            None => builder.set_null_at(index),
-        }
-    }
-    Ok(builder.build().to_serialized_bytes())
-}
-
 /// Upsert Arrow rows into a data-evolution table without primary keys.
 /// Internal executor for `TableUpdate::upsert_by_arrow_with_key`. Existing
 /// keys are updated by row ID; new keys are appended.
@@ -379,34 +369,130 @@ impl TableUpsert {
         let fields = schema.partition_fields();
         let mut splits = Vec::new();
         for split in plan.splits() {
-            if partitions.contains(&partition_key(split.partition(), 
&fields)?) {
+            if partitions.contains(&canonical_partition_key(split.partition(), 
&fields)?) {
                 splits.push(split.clone());
             }
         }
         Ok(Cow::Owned(splits))
     }
 
-    /// Match source keys against the target snapshot, stage row-ID updates and
-    /// new rows, then return both sets of commit messages as one operation.
-    #[must_use = "commit messages must be passed to TableCommit"]
-    pub(super) async fn prepare_commit(self) -> 
crate::Result<Vec<CommitMessage>> {
-        let source = self.key_rows()?;
-        if source.num_rows() == 0 {
-            return Err(invalid("Input data is empty"));
+    /// Each column domain is a superset of the input tuples. The matcher still
+    /// checks complete keys, including NULLs, after index and reader pruning.
+    fn lookup_filter(&self, source: &RecordBatch) -> 
crate::Result<Option<Predicate>> {
+        let builder = PredicateBuilder::new(self.table.schema().fields());
+        let mut predicates = Vec::new();
+        for key in &self.keys {
+            // Partition sets compare raw BinaryRows, which may use different
+            // variable-length storage for Java NULL timestamps and decimals.
+            // matching_splits checks their canonical logical identity instead.
+            if self.table.schema().partition_keys().contains(key) {
+                continue;
+            }
+            let column = unique_column_index(&source.schema(), key)?;
+            // Arrow row encoding preserves NaN payloads and signed zero. SQL
+            // equality need not share that identity, so retain the full 
domain.
+            if matches!(
+                source.column(column).data_type(),
+                DataType::Float32 | DataType::Float64
+            ) {
+                continue;
+            }
+            let field = self
+                .table
+                .schema()
+                .fields()
+                .iter()
+                .find(|field| field.name() == key)
+                .unwrap();
+            // BLOB keys can be matched as Arrow bytes but have no scalar 
Datum.
+            if field.data_type().is_blob_type() {
+                continue;
+            }
+            let mut seen = HashSet::new();
+            let mut values = Vec::new();
+            let mut has_null = false;
+            for row in 0..source.num_rows() {
+                match extract_datum_from_arrow(source, row, column, 
field.data_type())? {
+                    Some(value) => {
+                        if seen.insert(crate::btree::serialize_datum(&value, 
field.data_type())) {
+                            values.push(value);
+                        }
+                    }
+                    None => has_null = true,
+                }
+            }
+            let mut alternatives = Vec::new();
+            if values.len() == 1 {
+                alternatives.push(builder.equal(key.as_str(), 
values.remove(0))?);
+            } else if !values.is_empty() {
+                alternatives.push(builder.is_in(key.as_str(), values)?);
+            }
+            if has_null {
+                alternatives.push(builder.is_null(key.as_str())?);
+            }
+            if !alternatives.is_empty() {
+                predicates.push(Predicate::or(alternatives));
+            }
         }
-        let mut matcher = UpsertKeyMatcher::new(&source, self.keys.clone())?;
+        Ok((!predicates.is_empty()).then(|| Predicate::and(predicates)))
+    }
 
-        let mut read_builder = self.table.new_read_builder();
+    async fn match_existing(
+        &self,
+        source: &RecordBatch,
+        matcher: &mut UpsertKeyMatcher,
+    ) -> crate::Result<Plan> {
+        // Mutation lookups must include unindexed rows even when queries use
+        // fast mode, or a matching target would incorrectly become an insert.
+        let lookup_options = 
HashMap::from([("scalar-index.search-mode".into(), "full".into())]);
+        let mut lookup_table = self.table.copy_with_options(lookup_options);
+        if lookup_table.schema().core_options().global_index_enabled() {
+            let snapshot = 
super::time_travel::resolve_snapshot(&lookup_table).await?;
+            lookup_table = 
lookup_table.copy_with_pinned_snapshot(snapshot.as_ref());
+            if let Some(snapshot) = snapshot {
+                let partitions = 
self.serialized_partitions(source)?.into_iter().collect();
+                if !indexed_columns_unchanged(&lookup_table, &snapshot, 
&self.keys, &partitions)
+                    .await
+                    .unwrap_or(false)
+                {
+                    lookup_table = 
lookup_table.copy_with_options(HashMap::from([(
+                        "global-index.enabled".into(),
+                        "false".into(),
+                    )]));
+                }
+            }
+        }
+        let mut scan_builder = lookup_table.new_read_builder();
+        if let Some(filter) = self.lookup_filter(source)? {
+            scan_builder.with_filter(filter);
+        }
+        let plan = scan_builder.new_scan().plan_all().await?;
+        // Split row ranges retain index pruning. Exact tuple matching below
+        // avoids evaluating a potentially large IN list again for every row.
+        let mut read_builder = lookup_table.new_read_builder();
         let mut projection = 
self.keys.iter().map(String::as_str).collect::<Vec<_>>();
         projection.push(ROW_ID);
         read_builder.with_projection(&projection)?;
-        let plan = read_builder.new_scan().plan_all().await?;
         let read = read_builder.new_read()?.for_update();
-        let splits = self.matching_splits(&source, &plan)?;
+        let splits = self.matching_splits(source, &plan)?;
         let mut stream = read.to_arrow(splits.as_ref())?;
         while let Some(batch) = stream.try_next().await? {
             matcher.add_existing_batch(&batch)?;
         }
+        Ok(plan)
+    }
+
+    /// Match source keys against the target snapshot, stage row-ID updates and
+    /// new rows, then return both sets of commit messages as one operation.
+    #[must_use = "commit messages must be passed to TableCommit"]
+    pub(super) async fn prepare_commit(self) -> 
crate::Result<Vec<CommitMessage>> {
+        let source = self.key_rows()?;
+        if source.num_rows() == 0 {
+            return Err(invalid("Input data is empty"));
+        }
+        let mut matcher = UpsertKeyMatcher::new(&source, self.keys.clone())?;
+
+        let plan = self.match_existing(&source, &mut matcher).await?;
         let (matched_indices, row_ids, new_indices) = matcher.finish();
 
         // Validate append field sets before staging any updates or new files.
@@ -475,7 +561,10 @@ mod tests {
     use super::*;
     use crate::catalog::Identifier;
     use crate::io::FileIOBuilder;
-    use crate::spec::{DataType as PaimonType, DecimalType, TimestampType, 
VarCharType};
+    use crate::spec::{
+        DataEvolutionIndexSourceMeta, DataType as PaimonType, IndexManifest, 
TimestampType,
+        VarCharType,
+    };
     use crate::spec::{IntType, Schema as PaimonSchema, TableSchema};
     use crate::table::TableCommit;
     use arrow_array::{Array, Int32Array, StringArray};
@@ -572,6 +661,758 @@ mod tests {
         rows
     }
 
+    #[tokio::test]
+    async fn upsert_key_lookup_uses_btree_row_ranges() {
+        let table = numeric_table("memory:/upsert_btree_lookup").await;
+        table
+            .new_global_index_build_builder()
+            .with_index_column("key")
+            .execute()
+            .await
+            .unwrap();
+        let mut upsert = TableUpsert::new(
+            &table,
+            "upsert".into(),
+            vec!["key".into()],
+            vec!["value".into()],
+        )
+        .unwrap();
+        upsert
+            .add_batch(numeric_source(
+                vec![1],
+                Arc::new(Int32Array::from(vec![42])),
+            ))
+            .unwrap();
+        let source = upsert.key_rows().unwrap();
+        let mut matcher = UpsertKeyMatcher::new(&source, 
upsert.keys.clone()).unwrap();
+        let plan = upsert.match_existing(&source, &mut matcher).await.unwrap();
+        assert_eq!(plan.snapshot_id(), Some(2));
+        assert!(plan
+            .splits()
+            .iter()
+            .all(|split| { split.row_ranges() == 
Some(&[crate::table::RowRange::new(0, 1)][..]) }));
+        let (indices, mut row_ids, new) = matcher.finish();
+        row_ids.sort();
+        assert_eq!(indices, vec![0, 0]);
+        assert_eq!(row_ids, vec![0, 1]);
+        assert!(new.is_empty());
+        TableCommit::new(table.clone(), "upsert".into())
+            .commit(upsert.prepare_commit().await.unwrap())
+            .await
+            .unwrap();
+        assert_eq!(
+            numeric_rows(&table).await,
+            vec![
+                (0, 1, Some(42), Some(10)),
+                (1, 1, Some(42), Some(11)),
+                (2, 2, Some(0), Some(12)),
+            ]
+        );
+        commit_upsert(
+            &table,
+            vec![numeric_source(
+                vec![9],
+                Arc::new(Int32Array::from(vec![49])),
+            )],
+            vec!["key".into()],
+        )
+        .await;
+        assert_eq!(numeric_rows(&table).await[3], (3, 9, Some(49), None));
+    }
+
+    async fn commit_upsert(table: &Table, batches: Vec<RecordBatch>, keys: 
Vec<String>) {
+        let mut update = table.new_write_builder().new_update().unwrap();
+        update.with_update_type(vec!["value".into()]).unwrap();
+        let messages = update
+            .upsert_by_arrow_with_key(batches, keys)
+            .await
+            .unwrap();
+        TableCommit::new(table.clone(), "upsert".into())
+            .commit(messages)
+            .await
+            .unwrap();
+    }
+
+    async fn values_by_row_id(table: &Table) -> Vec<(i64, i32)> {
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&[ROW_ID, "value"]).unwrap();
+        let plan = builder.new_scan().plan_all().await.unwrap();
+        let batches: Vec<RecordBatch> = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        let mut rows = Vec::new();
+        for batch in batches {
+            let ids = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap();
+            let values = batch
+                .column(1)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            rows.extend((0..batch.num_rows()).map(|row| (ids.value(row), 
values.value(row))));
+        }
+        rows.sort();
+        rows
+    }
+
+    #[tokio::test]
+    async fn upsert_key_lookup_includes_unindexed_rows_in_fast_mode() {
+        for indexed in [false, true] {
+            let table = 
numeric_table(&format!("memory:/upsert_partial_index_{indexed}")).await;
+            let table = table.copy_with_options(HashMap::from([(
+                "scalar-index.search-mode".into(),
+                "fast".into(),
+            )]));
+            if indexed {
+                table
+                    .new_global_index_build_builder()
+                    .with_index_column("key")
+                    .execute()
+                    .await
+                    .unwrap();
+            }
+            let appended = RecordBatch::try_from_iter([
+                ("key", Arc::new(Int32Array::from(vec![1, 3])) as ArrayRef),
+                ("value", Arc::new(Int32Array::from(vec![0, 0])) as ArrayRef),
+                ("keep", Arc::new(Int32Array::from(vec![13, 14])) as ArrayRef),
+            ])
+            .unwrap();
+            let mut write = table.new_write_builder().new_write().unwrap();
+            write.write_arrow_batch(&appended).await.unwrap();
+            TableCommit::new(table.clone(), "append".into())
+                .commit(write.prepare_commit().await.unwrap())
+                .await
+                .unwrap();
+            write.close().await;
+            commit_upsert(
+                &table,
+                vec![numeric_source(
+                    vec![1, 3, 4],
+                    Arc::new(Int32Array::from(vec![41, 43, 44])),
+                )],
+                vec!["key".into()],
+            )
+            .await;
+            assert_eq!(
+                numeric_rows(&table).await,
+                vec![
+                    (0, 1, Some(41), Some(10)),
+                    (1, 1, Some(41), Some(11)),
+                    (2, 2, Some(0), Some(12)),
+                    (3, 1, Some(41), Some(13)),
+                    (4, 3, Some(43), Some(14)),
+                    (5, 4, Some(44), None),
+                ],
+                "indexed={indexed}"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn upsert_ignore_policy_keeps_unchanged_key_indexes() {
+        for update_payload in [false, true] {
+            let table =
+                
numeric_table(&format!("memory:/upsert_ignore_fresh_{update_payload}")).await;
+            let table = table.copy_with_options(HashMap::from([(
+                "global-index.column-update-action".into(),
+                "IGNORE".into(),
+            )]));
+            table
+                .new_global_index_build_builder()
+                .with_index_column("key")
+                .execute()
+                .await
+                .unwrap();
+            if update_payload {
+                let batch = RecordBatch::try_from_iter([
+                    (ROW_ID, Arc::new(Int64Array::from(vec![0, 1])) as 
ArrayRef),
+                    (
+                        "value",
+                        Arc::new(Int32Array::from(vec![11, 12])) as ArrayRef,
+                    ),
+                ])
+                .unwrap();
+                let update = table.new_write_builder().new_update().unwrap();
+                TableCommit::new(table.clone(), "payload".into())
+                    .commit(
+                        update
+                            .update_by_arrow_with_row_id(vec![batch])
+                            .await
+                            .unwrap(),
+                    )
+                    .await
+                    .unwrap();
+            }
+            let (plan, ids, new) = numeric_lookup(&table, 1).await;
+            assert!(!plan.splits().is_empty());
+            assert!(
+                plan.splits().iter().all(|split| {
+                    split.row_ranges() == 
Some(&[crate::table::RowRange::new(0, 1)][..])
+                }),
+                "payload update={update_payload}"
+            );
+            assert_eq!(ids, vec![0, 1]);
+            assert!(new.is_empty());
+        }
+    }
+
+    async fn numeric_lookup(table: &Table, key: i32) -> (Plan, Vec<i64>, 
Vec<usize>) {
+        let mut upsert = TableUpsert::new(
+            table,
+            "upsert".into(),
+            vec!["key".into()],
+            vec!["value".into()],
+        )
+        .unwrap();
+        upsert
+            .add_batch(numeric_source(
+                vec![key],
+                Arc::new(Int32Array::from(vec![43])),
+            ))
+            .unwrap();
+        let source = upsert.key_rows().unwrap();
+        let mut matcher = UpsertKeyMatcher::new(&source, 
upsert.keys.clone()).unwrap();
+        let plan = upsert.match_existing(&source, &mut matcher).await.unwrap();
+        let (_, mut ids, new) = matcher.finish();
+        ids.sort();
+        (plan, ids, new)
+    }
+
+    #[tokio::test]
+    async fn upsert_checks_index_sources_only_under_ignore() {
+        for action in ["IGNORE", "THROW_ERROR", "DROP_PARTITION_INDEX"] {
+            for (label, source) in [
+                ("missing", None),
+                ("malformed", Some(vec![1, 2, 3])),
+                (
+                    "future",
+                    
Some(DataEvolutionIndexSourceMeta::new(100).unwrap().serialize()),
+                ),
+            ] {
+                let table = 
numeric_table(&format!("memory:/upsert_source_{action}_{label}")).await;
+                let table = table.copy_with_options(HashMap::from([(
+                    "global-index.column-update-action".into(),
+                    action.into(),
+                )]));
+                table
+                    .new_global_index_build_builder()
+                    .with_index_column("key")
+                    .execute()
+                    .await
+                    .unwrap();
+                let manager = table.snapshot_manager();
+                let snapshot = 
manager.get_latest_snapshot().await.unwrap().unwrap();
+                let path = 
manager.manifest_path(snapshot.index_manifest().unwrap());
+                let mut entries = IndexManifest::read(table.file_io(), 
&path).await.unwrap();
+                for entry in &mut entries {
+                    entry
+                        .index_file
+                        .global_index_meta
+                        .as_mut()
+                        .unwrap()
+                        .source_meta = source.clone();
+                }
+                IndexManifest::write(table.file_io(), &path, &entries)
+                    .await
+                    .unwrap();
+                let (plan, ids, new) = numeric_lookup(&table, 1).await;
+                assert!(!plan.splits().is_empty());
+                if action == "IGNORE" {
+                    assert!(
+                        plan.splits()
+                            .iter()
+                            .all(|split| split.row_ranges().is_none()),
+                        "action={action}, source={label}"
+                    );
+                } else {
+                    assert!(
+                        plan.splits().iter().all(|split| {
+                            split.row_ranges() == 
Some(&[crate::table::RowRange::new(0, 1)][..])
+                        }),
+                        "action={action}, source={label}"
+                    );
+                }
+                assert_eq!(ids, vec![0, 1]);
+                assert!(new.is_empty());
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn upsert_key_lookup_ignores_stale_indexes_under_ignore_policy() {
+        let table = 
numeric_table("memory:/upsert_ignored_index_updates").await;
+        let table = table.copy_with_options(HashMap::from([(
+            "global-index.column-update-action".into(),
+            "IGNORE".into(),
+        )]));
+        table
+            .new_global_index_build_builder()
+            .with_index_column("key")
+            .execute()
+            .await
+            .unwrap();
+        let changed_keys = RecordBatch::try_from_iter([
+            (ROW_ID, Arc::new(Int64Array::from(vec![0, 1])) as ArrayRef),
+            ("key", Arc::new(Int32Array::from(vec![3, 3])) as ArrayRef),
+        ])
+        .unwrap();
+        let update = table.new_write_builder().new_update().unwrap();
+        TableCommit::new(table.clone(), "change-keys".into())
+            .commit(
+                update
+                    .update_by_arrow_with_row_id(vec![changed_keys])
+                    .await
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        commit_upsert(
+            &table,
+            vec![numeric_source(
+                vec![3, 4],
+                Arc::new(Int32Array::from(vec![43, 44])),
+            )],
+            vec!["key".into()],
+        )
+        .await;
+        assert_eq!(
+            numeric_rows(&table).await,
+            vec![
+                (0, 3, Some(43), Some(10)),
+                (1, 3, Some(43), Some(11)),
+                (2, 2, Some(0), Some(12)),
+                (3, 4, Some(44), None),
+            ]
+        );
+        // Dropping and rebuilding makes the updated keys safe to probe again.
+        table
+            .new_global_index_drop_builder()
+            .with_index_column("key")
+            .execute()
+            .await
+            .unwrap();
+        table
+            .new_global_index_build_builder()
+            .with_index_column("key")
+            .execute()
+            .await
+            .unwrap();
+        let (plan, ids, new) = numeric_lookup(&table, 3).await;
+        assert!(!plan.splits().is_empty());
+        assert!(plan
+            .splits()
+            .iter()
+            .all(|split| { split.row_ranges() == 
Some(&[crate::table::RowRange::new(0, 1)][..]) }));
+        assert_eq!(ids, vec![0, 1]);
+        assert!(new.is_empty());
+    }
+
+    #[tokio::test]
+    async fn upsert_key_lookup_preserves_updated_keys_and_deleted_rows() {
+        let table = numeric_table("memory:/upsert_changed_keys").await;
+        let table = table.copy_with_options(HashMap::from([
+            ("deletion-vectors.enabled".into(), "true".into()),
+            (
+                "global-index.column-update-action".into(),
+                "DROP_PARTITION_INDEX".into(),
+            ),
+        ]));
+        table
+            .new_global_index_build_builder()
+            .with_index_column("key")
+            .execute()
+            .await
+            .unwrap();
+        let changed_key = RecordBatch::try_from_iter([
+            (ROW_ID, Arc::new(Int64Array::from(vec![2])) as ArrayRef),
+            ("key", Arc::new(Int32Array::from(vec![3])) as ArrayRef),
+        ])
+        .unwrap();
+        let update = table.new_write_builder().new_update().unwrap();
+        TableCommit::new(table.clone(), "change-key".into())
+            .commit(
+                update
+                    .update_by_arrow_with_row_id(vec![changed_key])
+                    .await
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        commit_upsert(
+            &table,
+            vec![numeric_source(
+                vec![3],
+                Arc::new(Int32Array::from(vec![43])),
+            )],
+            vec!["key".into()],
+        )
+        .await;
+        assert_eq!(numeric_rows(&table).await[2], (2, 3, Some(43), Some(12)));
+        // Rebuild so the next lookup encounters an index entry for a deleted 
row.
+        table
+            .new_global_index_build_builder()
+            .with_index_column("key")
+            .execute()
+            .await
+            .unwrap();
+        TableCommit::new(table.clone(), "delete".into())
+            .commit(update.delete_by_row_id(vec![0, 1]).await.unwrap())
+            .await
+            .unwrap();
+        commit_upsert(
+            &table,
+            vec![numeric_source(
+                vec![1],
+                Arc::new(Int32Array::from(vec![41])),
+            )],
+            vec!["key".into()],
+        )
+        .await;
+        assert_eq!(
+            numeric_rows(&table).await,
+            vec![(2, 3, Some(43), Some(12)), (3, 1, Some(41), None)]
+        );
+    }
+
+    #[tokio::test]
+    async fn 
upsert_composite_btree_preserves_nulls_partitions_and_tuple_identity() {
+        let schema = PaimonSchema::builder()
+            .column("part", PaimonType::VarChar(VarCharType::string_type()))
+            .column("key", PaimonType::Int(IntType::new()))
+            .column("tag", PaimonType::VarChar(VarCharType::string_type()))
+            .column("value", PaimonType::Int(IntType::new()))
+            .partition_keys(["part"])
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("db", "upsert_composite"),
+            "memory:/upsert_composite_btree".into(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let batch = |parts: Vec<&str>,
+                     keys: Vec<Option<i32>>,
+                     tags: Vec<Option<&str>>,
+                     values: Vec<i32>| {
+            RecordBatch::try_from_iter([
+                ("part", Arc::new(StringArray::from(parts)) as ArrayRef),
+                ("key", Arc::new(Int32Array::from(keys)) as ArrayRef),
+                ("tag", Arc::new(StringArray::from(tags)) as ArrayRef),
+                ("value", Arc::new(Int32Array::from(values)) as ArrayRef),
+            ])
+            .unwrap()
+        };
+        let initial = batch(
+            vec!["a", "a", "a", "a", "a", "b"],
+            vec![Some(1), Some(2), None, Some(1), Some(1), Some(1)],
+            vec![Some("x"), Some("y"), Some("x"), None, Some("y"), Some("x")],
+            vec![0; 6],
+        );
+        let mut write = table.new_write_builder().new_write().unwrap();
+        write.write_arrow_batch(&initial).await.unwrap();
+        TableCommit::new(table.clone(), "seed".into())
+            .commit(write.prepare_commit().await.unwrap())
+            .await
+            .unwrap();
+        write.close().await;
+        table
+            .new_global_index_build_builder()
+            .with_index_columns(&["key", "tag"])
+            .execute()
+            .await
+            .unwrap();
+        commit_upsert(
+            &table,
+            vec![batch(
+                vec!["a"; 6],
+                vec![Some(1), Some(2), None, Some(1), Some(1), Some(9)],
+                vec![Some("x"), Some("y"), Some("x"), None, Some("x"), 
Some("x")],
+                vec![10, 20, 30, 40, 50, 60],
+            )],
+            vec!["key".into(), "tag".into()],
+        )
+        .await;
+        let mut builder = table.new_read_builder();
+        builder
+            .with_projection(&["part", "key", "tag", "value"])
+            .unwrap();
+        let plan = builder.new_scan().plan_all().await.unwrap();
+        let batches: Vec<RecordBatch> = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        let mut rows = Vec::new();
+        for batch in batches {
+            let parts = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let keys = batch
+                .column(1)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            let tags = batch
+                .column(2)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let values = batch
+                .column(3)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            rows.extend((0..batch.num_rows()).map(|row| {
+                (
+                    parts.value(row).to_string(),
+                    (!keys.is_null(row)).then(|| keys.value(row)),
+                    (!tags.is_null(row)).then(|| tags.value(row).to_string()),
+                    values.value(row),
+                )
+            }));
+        }
+        rows.sort();
+        // (1, y) is only a cross-product candidate. Partition b must not 
match.
+        let expected = vec![
+            ("a", None, Some("x"), 30),
+            ("a", Some(1), None, 40),
+            ("a", Some(1), Some("x"), 50),
+            ("a", Some(1), Some("y"), 0),
+            ("a", Some(2), Some("y"), 20),
+            ("a", Some(9), Some("x"), 60),
+            ("b", Some(1), Some("x"), 0),
+        ]
+        .into_iter()
+        .map(|(part, key, tag, value)| (part.to_string(), key, 
tag.map(str::to_string), value))
+        .collect::<Vec<_>>();
+        assert_eq!(rows, expected);
+    }
+
+    #[tokio::test]
+    async fn 
upsert_large_composite_domains_keep_index_pruning_and_exact_matches() {
+        for large_domain in [false, true] {
+            let schema = PaimonSchema::builder()
+                .column("key", PaimonType::Int(IntType::new()))
+                .column("tag", PaimonType::Int(IntType::new()))
+                .column("value", PaimonType::Int(IntType::new()))
+                .option("data-evolution.enabled", "true")
+                .option("row-tracking.enabled", "true")
+                .build()
+                .unwrap();
+            let table = Table::new(
+                FileIOBuilder::new("memory").build().unwrap(),
+                Identifier::new("db", "large_composite_upsert"),
+                
format!("memory:/upsert_large_composite_domains_{large_domain}"),
+                TableSchema::new(0, &schema),
+                None,
+            );
+            let batch = |keys: Vec<i32>, tags: Vec<i32>, value: i32| {
+                let count = keys.len();
+                RecordBatch::try_from_iter([
+                    ("key", Arc::new(Int32Array::from(keys)) as ArrayRef),
+                    ("tag", Arc::new(Int32Array::from(tags)) as ArrayRef),
+                    (
+                        "value",
+                        Arc::new(Int32Array::from(vec![value; count])) as 
ArrayRef,
+                    ),
+                ])
+                .unwrap()
+            };
+            let (keys, tags, count, stride, matches, candidates) = if 
large_domain {
+                (512, 8, 257, 2, 256, 256)
+            } else {
+                (128, 32, 17, 4, 16, 17 * 32)
+            };
+            let mut write = table.new_write_builder().new_write().unwrap();
+            write
+                .write_arrow_batch(&batch(
+                    (0..keys)
+                        .flat_map(|key| std::iter::repeat_n(key, tags as 
usize))
+                        .collect(),
+                    (0..keys).flat_map(|_| 0..tags).collect(),
+                    0,
+                ))
+                .await
+                .unwrap();
+            TableCommit::new(table.clone(), "seed".into())
+                .commit(write.prepare_commit().await.unwrap())
+                .await
+                .unwrap();
+            write.close().await;
+            table
+                .new_global_index_build_builder()
+                .with_index_columns(&["key", "tag"])
+                .execute()
+                .await
+                .unwrap();
+
+            let mut upsert = TableUpsert::new(
+                &table,
+                "upsert".into(),
+                vec!["key".into(), "tag".into()],
+                vec!["value".into()],
+            )
+            .unwrap();
+            // Exercise a large linear IN and a 17 x 17 Cartesian domain.
+            // The last tuple is absent; prefix candidates with other suffixes
+            // must not be updated or cause an existing match to become an 
insert.
+            upsert
+                .add_batch(batch(
+                    (0..count).map(|key| key * stride).collect(),
+                    (0..count)
+                        .map(|i| {
+                            if large_domain {
+                                7
+                            } else if i < matches {
+                                i
+                            } else {
+                                99
+                            }
+                        })
+                        .collect(),
+                    42,
+                ))
+                .unwrap();
+            let source = upsert.key_rows().unwrap();
+            let mut matcher = UpsertKeyMatcher::new(&source, 
upsert.keys.clone()).unwrap();
+            let plan = upsert.match_existing(&source, &mut 
matcher).await.unwrap();
+            let mut actual_candidates = 0;
+            for split in plan.splits() {
+                for range in split
+                    .row_ranges()
+                    .expect("large domains must retain index pruning")
+                {
+                    actual_candidates += range.to() - range.from() + 1;
+                }
+            }
+            assert_eq!(actual_candidates, candidates);
+            let (_, mut matched, new) = matcher.finish();
+            matched.sort();
+            let expected = (0..matches)
+                .map(|key| i64::from(key * stride * tags + if large_domain { 7 
} else { key }))
+                .collect::<Vec<_>>();
+            assert_eq!(matched, expected);
+            assert_eq!(new, vec![(count - 1) as usize]);
+            TableCommit::new(table.clone(), "upsert".into())
+                .commit(upsert.prepare_commit().await.unwrap())
+                .await
+                .unwrap();
+            let rows = values_by_row_id(&table).await;
+            assert_eq!(rows.len(), 4097);
+            for (row_id, value) in rows {
+                let updated = expected.contains(&row_id) || row_id == 4096;
+                assert_eq!(
+                    value,
+                    if updated { 42 } else { 0 },
+                    "row {row_id}, large={large_domain}"
+                );
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn upsert_blob_keys_fall_back_to_byte_matching() {
+        use crate::spec::BlobType;
+        use arrow_array::LargeBinaryArray;
+
+        let schema = PaimonSchema::builder()
+            .column("key", PaimonType::Blob(BlobType::new()))
+            .column("value", PaimonType::Int(IntType::new()))
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("db", "upsert_blob"),
+            "memory:/upsert_blob_key".into(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        for value in [41, 42] {
+            let source = RecordBatch::try_from_iter([
+                (
+                    "key",
+                    Arc::new(LargeBinaryArray::from_vec(vec![b"blob-key"])) as 
ArrayRef,
+                ),
+                ("value", Arc::new(Int32Array::from(vec![value])) as ArrayRef),
+            ])
+            .unwrap();
+            commit_upsert(&table, vec![source], vec!["key".into()]).await;
+        }
+        assert_eq!(values_by_row_id(&table).await, vec![(0, 42)]);
+    }
+
+    #[tokio::test]
+    async fn upsert_float_keys_keep_arrow_nan_and_signed_zero_identity() {
+        use crate::spec::DoubleType;
+        use arrow_array::Float64Array;
+
+        let schema = PaimonSchema::builder()
+            .column("key", PaimonType::Double(DoubleType::new()))
+            .column("value", PaimonType::Int(IntType::new()))
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("db", "upsert_float"),
+            "memory:/upsert_float_btree".into(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let batch = |keys: Vec<f64>, values: Vec<i32>| {
+            RecordBatch::try_from_iter([
+                ("key", Arc::new(Float64Array::from(keys)) as ArrayRef),
+                ("value", Arc::new(Int32Array::from(values)) as ArrayRef),
+            ])
+            .unwrap()
+        };
+        let mut write = table.new_write_builder().new_write().unwrap();
+        write
+            .write_arrow_batch(&batch(vec![f64::NAN, -0.0, 0.0, 1.0], vec![0; 
4]))
+            .await
+            .unwrap();
+        TableCommit::new(table.clone(), "seed".into())
+            .commit(write.prepare_commit().await.unwrap())
+            .await
+            .unwrap();
+        write.close().await;
+        table
+            .new_global_index_build_builder()
+            .with_index_column("key")
+            .execute()
+            .await
+            .unwrap();
+        commit_upsert(
+            &table,
+            vec![batch(vec![f64::NAN, -0.0], vec![41, 42])],
+            vec!["key".into()],
+        )
+        .await;
+        assert_eq!(
+            values_by_row_id(&table).await,
+            vec![(0, 41), (1, 42), (2, 0), (3, 0)]
+        );
+    }
+
     #[tokio::test]
     async fn 
upsert_casts_surviving_matched_values_and_ignores_other_payloads() {
         let table = numeric_table("memory:/upsert_selected").await;
@@ -898,44 +1739,58 @@ mod tests {
         );
     }
 
-    #[test]
-    fn partition_identity_ignores_java_null_variable_storage() {
-        let fields = vec![
-            DataField::new(
-                0,
-                "ts".into(),
+    #[tokio::test]
+    async fn upsert_finds_java_padded_null_timestamp_partition() {
+        let schema = PaimonSchema::builder()
+            .column(
+                "part",
                 PaimonType::Timestamp(TimestampType::new(6).unwrap()),
-            ),
-            DataField::new(
-                1,
-                "amount".into(),
-                PaimonType::Decimal(DecimalType::new(20, 2).unwrap()),
-            ),
-            DataField::new(
-                2,
-                "label".into(),
-                PaimonType::VarChar(VarCharType::new(100).unwrap()),
-            ),
-        ];
-        let mut builder = BinaryRowBuilder::new(3);
-        builder.set_null_at(0);
-        builder.set_null_at(1);
-        builder.write_string(2, "after-null-fields");
-        let canonical = builder.build();
-        let mut java = canonical.data()[..32].to_vec();
-        // BinaryWriter.writeTimestamp(NULL, 6) reserves 8 bytes;
-        // writeDecimal(NULL, 20) reserves another 16, even though both are 
NULL.
-        java.extend_from_slice(&[0; 24]);
-        java.extend_from_slice(&canonical.data()[32..]);
-        java[8..16].copy_from_slice(&(32_u64 << 32).to_le_bytes());
-        java[16..24].copy_from_slice(&(40_u64 << 32).to_le_bytes());
-        let offset_and_size = 
u64::from_le_bytes(canonical.data()[24..32].try_into().unwrap());
-        java[24..32].copy_from_slice(&(offset_and_size + (24_u64 << 
32)).to_le_bytes());
-        let java = BinaryRow::from_bytes(3, java);
-        assert_ne!(java.to_serialized_bytes(), 
canonical.to_serialized_bytes());
-        assert_eq!(
-            partition_key(&java, &fields).unwrap(),
-            canonical.to_serialized_bytes()
+            )
+            .column("key", PaimonType::Int(IntType::new()))
+            .column("value", PaimonType::Int(IntType::new()))
+            .partition_keys(["part"])
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("db", "padded_null_partition"),
+            "memory:/upsert_padded_null_partition".into(),
+            TableSchema::new(0, &schema),
+            None,
         );
+        let batch = |value| {
+            RecordBatch::try_from_iter([
+                (
+                    "part",
+                    Arc::new(arrow_array::TimestampMicrosecondArray::from(vec![
+                        None::<i64>,
+                    ])) as ArrayRef,
+                ),
+                ("key", Arc::new(Int32Array::from(vec![7])) as ArrayRef),
+                ("value", Arc::new(Int32Array::from(vec![value])) as ArrayRef),
+            ])
+            .unwrap()
+        };
+        let mut write = table.new_write_builder().new_write().unwrap();
+        write.write_arrow_batch(&batch(10)).await.unwrap();
+        let mut messages = write.prepare_commit().await.unwrap();
+        for message in &mut messages {
+            let row = 
BinaryRow::from_serialized_bytes(&message.partition).unwrap();
+            let mut padded = row.data().to_vec();
+            // Java reserves a variable slot for NULL TIMESTAMP(6), although
+            // the null bit makes its offset irrelevant to the logical value.
+            padded.extend_from_slice(&[0; 8]);
+            padded[8..16].copy_from_slice(&(16_u64 << 32).to_le_bytes());
+            message.partition = BinaryRow::from_bytes(1, 
padded).to_serialized_bytes();
+        }
+        TableCommit::new(table.clone(), "seed".into())
+            .commit(messages)
+            .await
+            .unwrap();
+        write.close().await;
+        commit_upsert(&table, vec![batch(42)], vec!["key".into()]).await;
+        assert_eq!(values_by_row_id(&table).await, vec![(0, 42)]);
     }
 }

Reply via email to