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 d04eb4bd feat(python): expose prepared sorted global index builds 
(#1088)
d04eb4bd is described below

commit d04eb4bd6a792bcfe3085bbf1092ae2048f19621
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 13:31:17 2026 +0800

    feat(python): expose prepared sorted global index builds (#1088)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  13 +
 bindings/python/src/context.rs                     |   1 +
 bindings/python/src/index_build.rs                 | 118 +++++
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/predicate.rs                   |  76 ++-
 bindings/python/src/table.rs                       |   6 +
 bindings/python/src/write.rs                       |   9 +
 bindings/python/tests/test_read.py                 |  33 +-
 crates/paimon/src/spec/core_options.rs             |  37 +-
 .../paimon/src/spec/data_evolution_index_source.rs | 104 ++++
 crates/paimon/src/spec/mod.rs                      |   2 +
 crates/paimon/src/spec/predicate.rs                | 142 ++++--
 crates/paimon/src/table/partition_filter.rs        |  21 +-
 .../src/table/sorted_global_index_build_builder.rs |  96 ++--
 .../preparation_tests.rs                           | 568 +++++++++++++++++++++
 .../sorted_global_index_build_builder/writer.rs    |  35 +-
 crates/paimon/src/table/table_commit.rs            |   5 +
 .../table_commit/global_index_source_check.rs      | 186 +++++++
 .../src/table/table_commit/row_tracking_tests.rs   |  13 +-
 crates/paimon/src/table/vector_search_common.rs    |  19 +-
 20 files changed, 1339 insertions(+), 146 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 4ff2065d..0974cea2 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -223,6 +223,7 @@ class Table:
     def new_stream_scan(self) -> StreamTableScan: ...
     def new_vector_search_builder(self) -> "VectorSearchBuilder": ...
     def new_batch_vector_search_builder(self) -> "BatchVectorSearchBuilder": 
...
+    def new_sorted_global_index_build_builder(self) -> 
"SortedGlobalIndexBuildBuilder": ...
     def new_batch_write_builder(self) -> "BatchWriteBuilder": ...
     def new_postpone_fixed_bucket_write_builder(self) -> 
"PostponeFixedBucketWriteBuilder": ...
     def new_stream_write_builder(self) -> "StreamWriteBuilder": ...
@@ -259,6 +260,18 @@ class Table:
         """
         ...
 
+class SortedGlobalIndexBuildBuilder:
+    def with_index_column(self, column: str) -> 
"SortedGlobalIndexBuildBuilder": ...
+    def with_index_type(self, index_type: str) -> 
"SortedGlobalIndexBuildBuilder": ...
+    def with_options(self, options: Dict[str, str]) -> 
"SortedGlobalIndexBuildBuilder": ...
+    def with_partition_filter(self, predicate: Dict[str, Any]) -> 
"SortedGlobalIndexBuildBuilder": ...
+    def build(self) -> List["CommitMessage"]:
+        """Build unpublished index files; the caller decides whether to commit 
or abort."""
+        ...
+    def execute(self) -> int:
+        """Build and publish indexes, rejecting a change to the planned 
snapshot."""
+        ...
+
 class CommitMessage:
     @staticmethod
     def deserialize(data: bytes, *, version: int = 14) -> "CommitMessage":
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 1c50299c..37a1f03e 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -726,6 +726,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     let this = PyModule::new(py, "datafusion")?;
     this.add_class::<PaimonCatalog>()?;
     this.add_class::<crate::table::PyTable>()?;
+    this.add_class::<crate::index_build::PySortedGlobalIndexBuildBuilder>()?;
     this.add_class::<crate::vector_search::PyVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyBatchVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyVectorScan>()?;
diff --git a/bindings/python/src/index_build.rs 
b/bindings/python/src/index_build.rs
new file mode 100644
index 00000000..2b216153
--- /dev/null
+++ b/bindings/python/src/index_build.rs
@@ -0,0 +1,118 @@
+// 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.
+
+//! Parameter and commit-message wrappers for the core sorted-index builder.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use paimon::spec::Predicate;
+use paimon::table::{SortedGlobalIndexBuildBuilder, Table};
+use paimon_datafusion::runtime::runtime;
+use pyo3::prelude::*;
+use pyo3::types::PyDict;
+
+use crate::error::to_py_err;
+use crate::predicate::dict_to_table_predicate;
+use crate::write::PyCommitMessage;
+
+#[pyclass(
+    name = "SortedGlobalIndexBuildBuilder",
+    module = "pypaimon_rust.datafusion"
+)]
+pub struct PySortedGlobalIndexBuildBuilder {
+    table: Arc<Table>,
+    column: Option<String>,
+    index_type: String,
+    options: HashMap<String, String>,
+    partition_filters: Vec<Predicate>,
+}
+
+impl PySortedGlobalIndexBuildBuilder {
+    pub(crate) fn new(table: Arc<Table>) -> Self {
+        Self {
+            table,
+            column: None,
+            index_type: "btree".into(),
+            options: HashMap::new(),
+            partition_filters: vec![],
+        }
+    }
+
+    fn builder(&self) -> paimon::Result<SortedGlobalIndexBuildBuilder<'_>> {
+        let mut builder = self.table.new_sorted_global_index_build_builder();
+        if let Some(column) = &self.column {
+            builder.with_index_column(column);
+        }
+        builder
+            .with_index_type(&self.index_type)
+            .with_options(self.options.clone());
+        for filter in &self.partition_filters {
+            builder.with_partition_filter(filter.clone())?;
+        }
+        Ok(builder)
+    }
+}
+
+#[pymethods]
+impl PySortedGlobalIndexBuildBuilder {
+    fn with_index_column(mut slf: PyRefMut<'_, Self>, column: String) -> 
PyRefMut<'_, Self> {
+        slf.column = Some(column);
+        slf
+    }
+
+    fn with_index_type(mut slf: PyRefMut<'_, Self>, index_type: String) -> 
PyRefMut<'_, Self> {
+        slf.index_type = index_type;
+        slf
+    }
+
+    fn with_options(
+        mut slf: PyRefMut<'_, Self>,
+        options: HashMap<String, String>,
+    ) -> PyRefMut<'_, Self> {
+        slf.options = options;
+        slf
+    }
+
+    fn with_partition_filter<'py>(
+        mut slf: PyRefMut<'py, Self>,
+        predicate: &Bound<'_, PyDict>,
+    ) -> PyResult<PyRefMut<'py, Self>> {
+        let filter = dict_to_table_predicate(predicate, slf.table.schema(), 
true)?;
+        // Validation and conjunction belong to the core builder.
+        let mut builder = slf.builder().map_err(to_py_err)?;
+        builder
+            .with_partition_filter(filter.clone())
+            .map_err(to_py_err)?;
+        slf.partition_filters.push(filter);
+        Ok(slf)
+    }
+
+    fn build(&self, py: Python<'_>) -> PyResult<Vec<PyCommitMessage>> {
+        let builder = self.builder().map_err(to_py_err)?;
+        let messages = py
+            .detach(|| runtime().block_on(builder.build()))
+            .map_err(to_py_err)?;
+        Ok(messages.into_iter().map(PyCommitMessage::new).collect())
+    }
+
+    fn execute(&self, py: Python<'_>) -> PyResult<usize> {
+        let builder = self.builder().map_err(to_py_err)?;
+        py.detach(|| runtime().block_on(builder.execute()))
+            .map_err(to_py_err)
+    }
+}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 900e19ef..2478c02d 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -21,6 +21,7 @@ mod blob;
 mod blob_uri_reader;
 mod context;
 mod error;
+mod index_build;
 mod merge_into;
 mod oss_cpp;
 mod predicate;
diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index f3f3ff7a..1986bad5 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -374,7 +374,7 @@ const METHOD_NOT_SUPPORTED: &[&str] = &["not"];
 ///
 /// Errors:
 /// - `ValueError` for unknown fields, missing keys, wrong literal counts, 
`None`
-///   literals, empty/missing `children`, non-dict children, or non-list
+///   comparison literals outside IN/NOT IN, empty/missing `children`, 
non-dict children, or non-list
 ///   `literals`/`children`.
 /// - `NotImplementedError` for unsupported operators or unsupported literal 
types.
 pub(crate) fn dict_to_predicate(
@@ -482,7 +482,7 @@ fn leaf_to_predicate(
 
     // Convert literals (DataType-driven), wrapping NotImplemented type 
messages
     // with field context.
-    let to_datums = |obj: Option<Bound<'_, PyAny>>| -> PyResult<Vec<Datum>> {
+    let to_nullable_datums = |obj: Option<Bound<'_, PyAny>>| -> 
PyResult<Vec<Option<Datum>>> {
         let mut out = Vec::new();
         if let Some(obj) = obj {
             let list = obj
@@ -490,18 +490,29 @@ fn leaf_to_predicate(
                 .map_err(|_| PyValueError::new_err("'literals' must be a 
list"))?;
             for item in list.iter() {
                 if item.is_none() {
-                    return Err(PyValueError::new_err(
-                        "None is not a valid comparison literal; use 
isNull/isNotNull",
-                    ));
+                    out.push(None);
+                    continue;
                 }
-                out.push(
+                out.push(Some(
                     py_to_datum(&item, &data_type)
                         .map_err(|e| with_field_context(e, &field, 
&data_type))?,
-                );
+                ));
             }
         }
         Ok(out)
     };
+    let to_datums = |obj| -> PyResult<Vec<Datum>> {
+        to_nullable_datums(obj)?
+            .into_iter()
+            .map(|value| {
+                value.ok_or_else(|| {
+                    PyValueError::new_err(
+                        "None is not a valid comparison literal; use 
isNull/isNotNull",
+                    )
+                })
+            })
+            .collect()
+    };
 
     let result = match method {
         "equal" => pb.equal(&field, one(to_datums(literals_obj)?)?),
@@ -518,20 +529,8 @@ fn leaf_to_predicate(
             ensure_no_literals(method, literals_obj)?;
             pb.is_not_null(&field)
         }
-        "in" => {
-            let ds = to_datums(literals_obj)?;
-            if ds.is_empty() {
-                return Err(PyValueError::new_err("'in' requires at least 1 
literal"));
-            }
-            pb.is_in(&field, ds)
-        }
-        "notIn" => {
-            let ds = to_datums(literals_obj)?;
-            if ds.is_empty() {
-                return Err(PyValueError::new_err("'notIn' requires at least 1 
literal"));
-            }
-            pb.is_not_in(&field, ds)
-        }
+        "in" => pb.is_in_with_nulls(&field, to_nullable_datums(literals_obj)?),
+        "notIn" => pb.is_not_in_with_nulls(&field, 
to_nullable_datums(literals_obj)?),
         "startsWith" => pb.starts_with(&field, one(to_datums(literals_obj)?)?),
         "endsWith" => pb.ends_with(&field, one(to_datums(literals_obj)?)?),
         "contains" => pb.contains(&field, one(to_datums(literals_obj)?)?),
@@ -1069,6 +1068,41 @@ mod tests {
         });
     }
 
+    #[test]
+    fn nullable_and_empty_set_literals_use_core_semantics() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let pb = PredicateBuilder::new(&fields);
+            for method in ["in", "notIn"] {
+                let dict = leaf_dict(py, method, "id", &[]);
+                let expected = if method == "in" {
+                    pb.is_in("id", vec![]).unwrap()
+                } else {
+                    pb.is_not_in("id", vec![]).unwrap()
+                };
+                assert_eq!(dict_to_predicate(&dict, &fields, true).unwrap(), 
expected);
+                dict.set_item(
+                    "literals",
+                    PyList::new(
+                        py,
+                        [
+                            py.None(),
+                            
1i32.into_pyobject(py).unwrap().into_any().unbind(),
+                        ],
+                    )
+                    .unwrap(),
+                )
+                .unwrap();
+                let expected = if method == "in" {
+                    pb.is_in("id", vec![Datum::Int(1)]).unwrap()
+                } else {
+                    Predicate::AlwaysFalse
+                };
+                assert_eq!(dict_to_predicate(&dict, &fields, true).unwrap(), 
expected);
+            }
+        });
+    }
+
     #[test]
     fn wrong_literal_count_raises_value_error() {
         Python::attach(|py| {
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index fd39b0c9..a2771e41 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -206,6 +206,12 @@ impl PyTable {
         
crate::vector_search::PyBatchVectorSearchBuilder::new(Arc::clone(&self.inner))
     }
 
+    fn new_sorted_global_index_build_builder(
+        &self,
+    ) -> crate::index_build::PySortedGlobalIndexBuildBuilder {
+        
crate::index_build::PySortedGlobalIndexBuildBuilder::new(Arc::clone(&self.inner))
+    }
+
     fn new_batch_write_builder(&self) -> PyBatchWriteBuilder {
         PyBatchWriteBuilder::new(Arc::clone(&self.inner))
     }
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index da7d6b9b..6f6901d4 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -1459,6 +1459,15 @@ pub struct PyCommitMessage {
     origin: Option<MessageOrigin>,
 }
 
+impl PyCommitMessage {
+    pub(crate) fn new(inner: CommitMessage) -> Self {
+        Self {
+            inner,
+            origin: None,
+        }
+    }
+}
+
 #[pymethods]
 impl PyCommitMessage {
     /// Export the Java `CommitMessageSerializer` v14 body (no version header).
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index abd0cf16..b98e5a59 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -1046,14 +1046,39 @@ def test_filter_out_of_range_raises():
                 {"method": "equal", "field": "small", "literals": [9999]})
 
 
-def test_filter_wrong_literal_count_raises():
[email protected]("literals", [[], [1, 2]])
+def test_filter_wrong_literal_count_raises(literals):
     with tempfile.TemporaryDirectory() as warehouse:
         table = _make_table_with_data(warehouse)
         b = table.new_read_builder()
         with pytest.raises(ValueError):
-            b.with_filter({"method": "equal", "field": "id", "literals": [1, 
2]})
-        with pytest.raises(ValueError):
-            b.with_filter({"method": "in", "field": "id", "literals": []})
+            b.with_filter({"method": "equal", "field": "id", "literals": 
literals})
+
+
[email protected]("method, literals, expected_ids", [
+    ("in", [], []),
+    ("notIn", [], [2, 3]),
+    ("in", [None], []),
+    ("notIn", [None], []),
+    ("in", [None, 10], [2]),
+    ("notIn", [None, 10], []),
+    ("in", [10], [2]),
+    ("notIn", [10], [3]),
+])
+def test_filter_set_predicates_follow_java_null_semantics(method, literals, 
expected_ids):
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.setdb")
+        ctx.sql("CREATE TABLE paimon.setdb.t (id INT, value INT)")
+        ctx.sql("INSERT INTO paimon.setdb.t VALUES (1, NULL), (2, 10), (3, 
20)")
+        table = PaimonCatalog({"warehouse": warehouse}).get_table("setdb.t")
+        builder = table.new_read_builder().with_filter({
+            "method": method, "field": "value", "literals": literals,
+        }).with_projection(["id"])
+        batches = builder.new_read().read(builder.new_scan().plan().splits())
+        actual_ids = sorted(value for batch in batches for value in 
batch.column(0).to_pylist())
+        assert actual_ids == expected_ids
 
 
 def test_filter_compound_with_unsupported_child_fails():
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index b48208f2..79354eea 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -36,6 +36,7 @@ const GLOBAL_INDEX_THREAD_NUM_OPTION: &str = 
"global-index.thread-num";
 const GLOBAL_INDEX_VINDEX_READ_THREAD_NUM_OPTION: &str = 
"global-index.vindex.read-thread-num";
 const GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION: &str = 
"global-index.column-update-action";
 pub(crate) const INDEX_FILE_IN_DATA_FILE_DIR_OPTION: &str = 
"index-file-in-data-file-dir";
+const SORTED_INDEX_RECORDS_PER_FILE_OPTION: &str = 
"sorted-index.records-per-file";
 const SORTED_INDEX_RECORDS_PER_RANGE_OPTION: &str = 
"sorted-index.records-per-range";
 const BTREE_INDEX_RECORDS_PER_RANGE_OPTION: &str = 
"btree-index.records-per-range";
 const BTREE_INDEX_CACHE_SIZE_OPTION: &str = "btree-index.cache-size";
@@ -163,6 +164,7 @@ const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 
2_000_000;
 pub(crate) const MAX_DYNAMIC_BUCKETS: i32 = 32768;
 const DYNAMIC_BUCKET_MAX_BUCKETS_OPTION: &str = "dynamic-bucket.max-buckets";
 const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
+const DEFAULT_SORTED_INDEX_RECORDS_PER_FILE: i64 = 25_000_000;
 const DEFAULT_GLOBAL_INDEX_THREAD_NUM: i64 = 32;
 pub(crate) const DEFAULT_GLOBAL_INDEX_VINDEX_READ_THREAD_NUM: usize = 64;
 const MAX_GLOBAL_INDEX_VINDEX_READ_THREAD_NUM: i64 = 
tokio::sync::Semaphore::MAX_PERMITS as i64;
@@ -1120,17 +1122,18 @@ impl<'a> CoreOptions<'a> {
     }
 
     pub fn sorted_index_records_per_range(&self) -> crate::Result<i64> {
-        let option = if self
-            .options
-            .contains_key(SORTED_INDEX_RECORDS_PER_RANGE_OPTION)
-        {
-            SORTED_INDEX_RECORDS_PER_RANGE_OPTION
-        } else {
-            BTREE_INDEX_RECORDS_PER_RANGE_OPTION
-        };
+        // Java SortedIndexOptions uses records-per-file as the primary key.
+        let option = [
+            SORTED_INDEX_RECORDS_PER_FILE_OPTION,
+            SORTED_INDEX_RECORDS_PER_RANGE_OPTION,
+            BTREE_INDEX_RECORDS_PER_RANGE_OPTION,
+        ]
+        .into_iter()
+        .find(|option| self.options.contains_key(*option))
+        .unwrap_or(SORTED_INDEX_RECORDS_PER_FILE_OPTION);
         let value = self
             .parse_i64_option(option)?
-            .unwrap_or(DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD);
+            .unwrap_or(DEFAULT_SORTED_INDEX_RECORDS_PER_FILE);
         if value <= 0 {
             return Err(crate::Error::DataInvalid {
                 message: format!("Option '{}' must be greater than 0, got: 
{}", option, value),
@@ -2344,7 +2347,7 @@ mod tests {
         );
         assert_eq!(
             core_options.sorted_index_records_per_range().unwrap(),
-            100_000
+            25_000_000
         );
         assert_eq!(
             core_options.btree_index_fallback_scan_max_size().unwrap(),
@@ -2631,6 +2634,7 @@ mod tests {
     #[test]
     fn test_sorted_index_records_per_range_rejects_invalid_values() {
         for option in [
+            SORTED_INDEX_RECORDS_PER_FILE_OPTION,
             SORTED_INDEX_RECORDS_PER_RANGE_OPTION,
             BTREE_INDEX_RECORDS_PER_RANGE_OPTION,
         ] {
@@ -2676,6 +2680,19 @@ mod tests {
                 .unwrap(),
             10
         );
+        let mut primary = options;
+        primary.insert(SORTED_INDEX_RECORDS_PER_FILE_OPTION.into(), 
"30".into());
+        assert_eq!(
+            CoreOptions::new(&primary)
+                .sorted_index_records_per_range()
+                .unwrap(),
+            30
+        );
+        // A bad primary value must fail rather than silently using an alias.
+        primary.insert(SORTED_INDEX_RECORDS_PER_FILE_OPTION.into(), 
"0".into());
+        assert!(CoreOptions::new(&primary)
+            .sorted_index_records_per_range()
+            .is_err());
     }
 
     #[test]
diff --git a/crates/paimon/src/spec/data_evolution_index_source.rs 
b/crates/paimon/src/spec/data_evolution_index_source.rs
new file mode 100644
index 00000000..510888dd
--- /dev/null
+++ b/crates/paimon/src/spec/data_evolution_index_source.rs
@@ -0,0 +1,104 @@
+// 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.
+
+//! Java DataEvolutionIndexSourceMeta, carried by GlobalIndexMeta.source_meta.
+
+use crate::{Error, Result};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DataEvolutionIndexSourceMeta {
+    scan_snapshot_id: i64,
+}
+
+impl DataEvolutionIndexSourceMeta {
+    const MAGIC: [u8; 4] = *b"DEIX";
+    const VERSION: i32 = 1;
+
+    pub fn new(scan_snapshot_id: i64) -> Result<Self> {
+        if scan_snapshot_id <= 0 {
+            return Err(invalid("Scan snapshot id must be positive."));
+        }
+        Ok(Self { scan_snapshot_id })
+    }
+
+    pub fn scan_snapshot_id(&self) -> i64 {
+        self.scan_snapshot_id
+    }
+
+    pub fn serialize(&self) -> Vec<u8> {
+        let mut bytes = Vec::with_capacity(16);
+        bytes.extend_from_slice(&Self::MAGIC);
+        bytes.extend_from_slice(&Self::VERSION.to_be_bytes());
+        bytes.extend_from_slice(&self.scan_snapshot_id.to_be_bytes());
+        bytes
+    }
+
+    pub fn is_data_evolution_meta(bytes: Option<&[u8]>) -> bool {
+        bytes.is_some_and(|bytes| bytes.starts_with(&Self::MAGIC))
+    }
+
+    pub fn deserialize(bytes: &[u8]) -> Result<Self> {
+        if bytes.len() != 16 || !Self::is_data_evolution_meta(Some(bytes)) {
+            return Err(invalid("Invalid data-evolution index source 
metadata."));
+        }
+        let version = i32::from_be_bytes(bytes[4..8].try_into().unwrap());
+        if version != Self::VERSION {
+            return Err(invalid(format!(
+                "Unsupported data-evolution index source version: {version}."
+            )));
+        }
+        Self::new(i64::from_be_bytes(bytes[8..16].try_into().unwrap()))
+    }
+}
+
+fn invalid(message: impl Into<String>) -> Error {
+    Error::DataInvalid {
+        message: message.into(),
+        source: None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn java_wire_format_and_invalid_frames() {
+        let java = [0x44, 0x45, 0x49, 0x58, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 
42];
+        let source = DataEvolutionIndexSourceMeta::new(42).unwrap();
+        assert_eq!(source.serialize(), java);
+        assert_eq!(
+            DataEvolutionIndexSourceMeta::deserialize(&java).unwrap(),
+            source
+        );
+        for length in 0..16 {
+            
assert!(DataEvolutionIndexSourceMeta::deserialize(&java[..length]).is_err());
+        }
+        for offset in [0, 7, 15] {
+            let mut invalid = java;
+            invalid[offset] = 0;
+            
assert!(DataEvolutionIndexSourceMeta::deserialize(&invalid).is_err());
+        }
+        let mut trailing = java.to_vec();
+        trailing.push(0);
+        assert!(DataEvolutionIndexSourceMeta::deserialize(&trailing).is_err());
+        assert!(!DataEvolutionIndexSourceMeta::is_data_evolution_meta(None));
+        assert!(!DataEvolutionIndexSourceMeta::is_data_evolution_meta(Some(
+            &[0, 0, 0, 1]
+        )));
+    }
+}
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 2a0df88b..8ef2af95 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -83,6 +83,8 @@ pub use index_file_meta::*;
 
 mod pk_index_source;
 pub use pk_index_source::*;
+mod data_evolution_index_source;
+pub use data_evolution_index_source::*;
 
 mod index_manifest;
 pub use index_manifest::{IndexManifest, IndexManifestEntry};
diff --git a/crates/paimon/src/spec/predicate.rs 
b/crates/paimon/src/spec/predicate.rs
index 3cfa72c2..e3107e8e 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -432,6 +432,28 @@ impl Predicate {
             Predicate::Not(inner) => *inner,
             Predicate::AlwaysTrue => Predicate::AlwaysFalse,
             Predicate::AlwaysFalse => Predicate::AlwaysTrue,
+            Predicate::Leaf {
+                column,
+                index,
+                data_type,
+                op,
+                literals,
+            } if literals.is_empty()
+                && matches!(op, PredicateOperator::In | 
PredicateOperator::NotIn) =>
+            {
+                // Java negates the leaf function, retaining its NULL 
exclusion.
+                Predicate::Leaf {
+                    column,
+                    index,
+                    data_type,
+                    op: if op == PredicateOperator::In {
+                        PredicateOperator::NotIn
+                    } else {
+                        PredicateOperator::In
+                    },
+                    literals,
+                }
+            }
             other => Predicate::Not(Box::new(other)),
         }
     }
@@ -923,26 +945,8 @@ fn parse_rest_leaf(
         "GREATER_OR_EQUAL" => binary(one_literal()?, &|l| 
builder.greater_or_equal(field, l)),
         "LESS_THAN" => binary(one_literal()?, &|l| builder.less_than(field, 
l)),
         "LESS_OR_EQUAL" => binary(one_literal()?, &|l| 
builder.less_or_equal(field, l)),
-        "IN" => {
-            // Java In: null literals never match; an empty/all-null list 
matches nothing.
-            let datums: Vec<Datum> = 
literals.iter().flatten().cloned().collect();
-            if datums.is_empty() {
-                Ok(Predicate::AlwaysFalse)
-            } else {
-                builder.is_in(field, datums)
-            }
-        }
-        "NOT_IN" => {
-            // Java NotIn: any null literal fails every row; an empty list 
keeps
-            // exactly the non-null rows.
-            if literals.iter().any(Option::is_none) {
-                Ok(Predicate::AlwaysFalse)
-            } else if literals.is_empty() {
-                builder.is_not_null(field)
-            } else {
-                builder.is_not_in(field, 
literals.iter().flatten().cloned().collect())
-            }
-        }
+        "IN" => builder.is_in_with_nulls(field, literals),
+        "NOT_IN" => builder.is_not_in_with_nulls(field, literals),
         "STARTS_WITH" => binary(one_literal()?, &|l| 
builder.starts_with(field, l)),
         "ENDS_WITH" => binary(one_literal()?, &|l| builder.ends_with(field, 
l)),
         "CONTAINS" => binary(one_literal()?, &|l| builder.contains(field, l)),
@@ -1405,19 +1409,31 @@ impl PredicateBuilder {
     // -- set operators --
 
     pub fn is_in(&self, field: &str, literals: Vec<Datum>) -> 
Result<Predicate> {
-        if literals.is_empty() {
-            return Ok(Predicate::AlwaysFalse);
-        }
         self.leaf(field, PredicateOperator::In, literals)
     }
 
     pub fn is_not_in(&self, field: &str, literals: Vec<Datum>) -> 
Result<Predicate> {
-        if literals.is_empty() {
-            return Ok(Predicate::AlwaysTrue);
-        }
         self.leaf(field, PredicateOperator::NotIn, literals)
     }
 
+    /// Java IN ignores NULL literals; an empty/all-NULL list matches nothing.
+    pub fn is_in_with_nulls(&self, field: &str, literals: Vec<Option<Datum>>) 
-> Result<Predicate> {
+        self.is_in(field, literals.into_iter().flatten().collect())
+    }
+
+    /// Java NOT IN fails every row if any literal is NULL.
+    pub fn is_not_in_with_nulls(
+        &self,
+        field: &str,
+        literals: Vec<Option<Datum>>,
+    ) -> Result<Predicate> {
+        if literals.iter().any(Option::is_none) {
+            self.resolve_field(field)?;
+            return Ok(Predicate::AlwaysFalse);
+        }
+        self.is_not_in(field, literals.into_iter().flatten().collect())
+    }
+
     // -- string operators --
 
     /// `field LIKE 'pat%'` shape. Empty pattern → `IsNotNull(field)` (every
@@ -1643,14 +1659,8 @@ impl PredicateBuilder {
                 });
             }
             PredicateOperator::In | PredicateOperator::NotIn => {
-                if !literals.is_empty() {
-                    return Ok(());
-                }
-                // Empty IN is handled at is_in()/is_not_in() level; this 
guards
-                // against direct leaf() misuse.
-                return Err(Error::ConfigInvalid {
-                    message: format!("{op} expects at least 1 literal, got 0"),
-                });
+                // Java retains empty set leaves so negation preserves NULL 
semantics.
+                return Ok(());
             }
             PredicateOperator::ArraysOverlap | 
PredicateOperator::ArrayContainsAll => return Ok(()),
             PredicateOperator::Between | PredicateOperator::NotBetween => (2, 
literals.len()),
@@ -2566,15 +2576,50 @@ mod tests {
     // ======================== Empty IN / NOT IN handling 
========================
 
     #[test]
-    fn test_in_empty_returns_always_false() {
+    fn test_empty_set_negation_preserves_java_null_semantics() {
         let pb = PredicateBuilder::new(&test_fields());
-        assert_eq!(pb.is_in("id", vec![]).unwrap(), Predicate::AlwaysFalse);
+        assert_eq!(
+            Predicate::negate(pb.is_in("id", vec![]).unwrap()),
+            pb.is_not_in("id", vec![]).unwrap()
+        );
+        assert_eq!(
+            Predicate::negate(pb.is_not_in("id", vec![]).unwrap()),
+            pb.is_in("id", vec![]).unwrap()
+        );
     }
 
     #[test]
-    fn test_not_in_empty_returns_always_true() {
-        let pb = PredicateBuilder::new(&test_fields());
-        assert_eq!(pb.is_not_in("id", vec![]).unwrap(), Predicate::AlwaysTrue);
+    fn nullable_set_literals_follow_java() {
+        let fields = vec![DataField::new(
+            0,
+            "id".into(),
+            DataType::Int(crate::spec::IntType::new()),
+        )];
+        let pb = PredicateBuilder::new(&fields);
+        let predicates = [
+            pb.is_in_with_nulls("id", vec![]).unwrap(),
+            pb.is_not_in_with_nulls("id", vec![]).unwrap(),
+            pb.is_in_with_nulls("id", vec![None, Some(Datum::Int(1))])
+                .unwrap(),
+            pb.is_not_in_with_nulls("id", vec![None, Some(Datum::Int(1))])
+                .unwrap(),
+        ];
+        for (value, expected) in [
+            (None, [false, false, false, false]),
+            (Some(1), [false, true, true, false]),
+            (Some(2), [false, true, false, false]),
+        ] {
+            let mut row = TestBinaryRowBuilder::new(1);
+            if let Some(value) = value {
+                row.write_int(0, value);
+            } else {
+                row.set_null_at(0);
+            }
+            let row = row.build();
+            for (predicate, expected) in predicates.iter().zip(expected) {
+                assert_eq!(eval_row(predicate, &row).unwrap(), expected);
+            }
+        }
     }
 
     // ======================== Constant absorption ========================
@@ -3825,9 +3870,6 @@ mod tests {
             ("NOT_BETWEEN", "[1,null]"),
             // NOT_IN with any null literal fails every row.
             ("NOT_IN", "[1,null]"),
-            // IN with only null literals matches nothing.
-            ("IN", "[null]"),
-            ("IN", "[]"),
         ] {
             let json = rest_leaf_json(function, "id", literals);
             assert!(
@@ -3839,6 +3881,20 @@ mod tests {
             );
         }
 
+        for literals in ["[]", "[null]"] {
+            let parsed =
+                Predicate::from_rest_json(&rest_leaf_json("IN", "id", 
literals), &fields).unwrap();
+            for value in [None, Some(1)] {
+                let mut row = TestBinaryRowBuilder::new(fields.len() as i32);
+                if let Some(value) = value {
+                    row.write_int(0, value);
+                } else {
+                    row.set_null_at(0);
+                }
+                assert!(!eval_row(&parsed, &row.build()).unwrap());
+            }
+        }
+
         // IN drops null literals and keeps the rest.
         let parsed =
             Predicate::from_rest_json(&rest_leaf_json("IN", "id", 
"[1,null,2]"), &fields).unwrap();
@@ -3854,7 +3910,7 @@ mod tests {
         assert!(matches!(
             &parsed,
             Predicate::Leaf {
-                op: PredicateOperator::IsNotNull,
+                op: PredicateOperator::NotIn,
                 ..
             }
         ));
diff --git a/crates/paimon/src/table/partition_filter.rs 
b/crates/paimon/src/table/partition_filter.rs
index 17ad4635..ce5dda04 100644
--- a/crates/paimon/src/table/partition_filter.rs
+++ b/crates/paimon/src/table/partition_filter.rs
@@ -21,12 +21,29 @@
 
 use crate::predicate_stats::data_leaf_may_match;
 use crate::spec::{
-    eval_row, extract_datum, BinaryRow, BinaryRowBuilder, DataField, Datum, 
ManifestFileMeta,
-    Predicate, PredicateBuilder, PredicateOperator,
+    eval_row, extract_datum, field_idx_to_partition_idx, BinaryRow, 
BinaryRowBuilder, DataField,
+    Datum, ManifestFileMeta, Predicate, PredicateBuilder, PredicateOperator,
 };
 use crate::table::stats_filter::FileStatsRows;
 use std::collections::HashSet;
 
+/// Explicit partition filters use table-field indices and must not contain 
data predicates.
+pub(super) fn validate_partition_filter(
+    table: &super::Table,
+    filter: &Predicate,
+) -> crate::Result<()> {
+    let mapping =
+        field_idx_to_partition_idx(table.schema().fields(), 
table.schema().partition_keys());
+    if table.schema().partition_keys().is_empty() || 
!filter.references_only_mapped_fields(&mapping)
+    {
+        return Err(crate::Error::ConfigInvalid {
+            message: "Partition filter must reference only partition keys of a 
partitioned table"
+                .to_string(),
+        });
+    }
+    Ok(())
+}
+
 /// Per-field min/max bounds for stats-based manifest pruning.
 #[derive(Debug, Clone)]
 pub(crate) struct FieldBounds {
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder.rs
index 043dd3bf..7b8e08e0 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder.rs
@@ -37,8 +37,8 @@ use 
super::sorted_global_index_options::SortedIndexWriteOptions;
 use crate::btree::key_serde::KeyComparator;
 use crate::btree::{make_key_comparator, serialize_datum};
 use crate::fm_index::{FMOptions, FMWriteOptions};
-use crate::spec::{CoreOptions, DataType, Datum};
-use crate::table::{CommitMessage, RowRange, SnapshotManager, Table, 
TableCommit};
+use crate::spec::{CoreOptions, DataType, Datum, Predicate};
+use crate::table::{CommitMessage, RowRange, Table, TableCommit};
 use crate::{Error, Result};
 use std::collections::HashMap;
 
@@ -68,6 +68,7 @@ pub struct SortedGlobalIndexBuildBuilder<'a> {
     index_column: Option<IndexColumns>,
     index_type: String,
     options: HashMap<String, String>,
+    partition_filter: Option<Predicate>,
 }
 
 /// Backward-compatible name retained for callers that used the original
@@ -81,6 +82,7 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
             index_column: None,
             index_type: BTREE_GLOBAL_INDEX_TYPE.to_string(),
             options: HashMap::new(),
+            partition_filter: None,
         }
     }
 
@@ -107,7 +109,55 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
         self
     }
 
+    /// Restrict the build to partitions using table-field indices.
+    /// Multiple calls combine the partition predicates with AND.
+    pub fn with_partition_filter(&mut self, filter: Predicate) -> Result<&mut 
Self> {
+        super::partition_filter::validate_partition_filter(self.table, 
&filter)?;
+        self.partition_filter = Some(match self.partition_filter.take() {
+            Some(previous) => Predicate::and(vec![previous, filter]),
+            None => filter,
+        });
+        Ok(self)
+    }
+
+    /// Build index files without publishing a snapshot, like Java's index 
builders.
+    /// The caller owns the returned messages and decides whether to commit or 
abort.
+    /// A build with no snapshot or uncovered rows returns an empty list.
+    pub async fn build(&self) -> Result<Vec<CommitMessage>> {
+        self.prepare().await.map(|(_, messages)| messages)
+    }
+
+    /// Build and commit, rejecting a concurrent change to the planned 
snapshot.
     pub async fn execute(&self) -> Result<usize> {
+        let (snapshot_id, messages) = self.prepare().await?;
+        if messages.is_empty() {
+            return Ok(0);
+        }
+        let count = messages
+            .iter()
+            .map(|message| message.new_index_files.len())
+            .sum();
+        self.new_commit()
+            .commit_if_latest_snapshot(
+                messages,
+                snapshot_id.expect("nonempty build has a snapshot"),
+            )
+            .await?;
+        Ok(count)
+    }
+
+    fn new_commit(&self) -> TableCommit {
+        TableCommit::new(
+            self.table.clone(),
+            format!(
+                "global-index-{}-create-{}",
+                self.index_type,
+                uuid::Uuid::new_v4()
+            ),
+        )
+    }
+
+    async fn prepare(&self) -> Result<(Option<i64>, Vec<CommitMessage>)> {
         // Building the index scans the table's rows.
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
 
@@ -159,21 +209,15 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
             .collect::<Vec<_>>();
         let extra_field_ids = 
(!extra_field_ids.is_empty()).then_some(extra_field_ids);
 
-        let snapshot_manager = SnapshotManager::new(
-            self.table.file_io().clone(),
-            self.table.location().to_string(),
-        );
-        let snapshot = snapshot_manager
-            .get_latest_snapshot()
-            .await?
-            .ok_or_else(|| Error::DataInvalid {
-                message: "Cannot build sorted global index without a 
snapshot".to_string(),
-                source: None,
-            })?;
+        let Some(snapshot) = 
self.table.snapshot_manager().get_latest_snapshot().await? else {
+            return Ok((None, vec![]));
+        };
 
-        let manifest_entries = self
-            .table
-            .new_read_builder()
+        let mut read_builder = self.table.new_read_builder();
+        if let Some(filter) = &self.partition_filter {
+            read_builder.with_filter(filter.clone());
+        }
+        let manifest_entries = read_builder
             .new_scan()
             .with_scan_all_files()
             .plan_manifest_entries(&snapshot)
@@ -198,7 +242,7 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
             &indexed,
         )?;
         if shards.is_empty() {
-            return Ok(0);
+            return Ok((Some(snapshot.id()), vec![]));
         }
 
         
crate::table::global_index_build_common::validate_existing_index_overlap(
@@ -214,14 +258,7 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
         )
         .await?;
 
-        let commit = TableCommit::new(
-            self.table.clone(),
-            format!(
-                "global-index-{}-create-{}",
-                index_type,
-                uuid::Uuid::new_v4()
-            ),
-        );
+        let commit = self.new_commit();
         let shard_count = shards.len();
         let mut messages = Vec::with_capacity(shard_count);
         for shard in shards {
@@ -243,13 +280,12 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
             messages.push(message);
         }
 
-        commit
-            .commit_if_latest_snapshot(messages, snapshot.id())
-            .await?;
-
-        Ok(shard_count)
+        Ok((Some(snapshot.id()), messages))
     }
 }
 
 #[cfg(test)]
 mod tests;
+
+#[cfg(test)]
+mod preparation_tests;
diff --git 
a/crates/paimon/src/table/sorted_global_index_build_builder/preparation_tests.rs
 
b/crates/paimon/src/table/sorted_global_index_build_builder/preparation_tests.rs
new file mode 100644
index 00000000..b23a7a49
--- /dev/null
+++ 
b/crates/paimon/src/table/sorted_global_index_build_builder/preparation_tests.rs
@@ -0,0 +1,568 @@
+// 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.
+
+use super::*;
+use crate::catalog::Identifier;
+use crate::io::FileIOBuilder;
+use crate::spec::{IntType, PredicateBuilder, Schema, TableSchema, VarCharType};
+use crate::table::TableWrite;
+use arrow_array::{Int32Array, Int64Array, RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+use futures::TryStreamExt;
+use std::sync::Arc;
+
+fn table(partitioned: bool, external: bool) -> Table {
+    let location = format!("memory:/index-preparation-{}", 
uuid::Uuid::new_v4());
+    let mut options = HashMap::from([
+        ("row-tracking.enabled".into(), "true".into()),
+        ("data-evolution.enabled".into(), "true".into()),
+        ("global-index.enabled".into(), "true".into()),
+        ("global-index.search-mode".into(), "full".into()),
+        ("sorted-index.records-per-range".into(), "2".into()),
+    ]);
+    if external {
+        options.insert(
+            "global-index.external-path".into(),
+            format!("{location}-external"),
+        );
+    }
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("name", DataType::VarChar(VarCharType::string_type()))
+        .column("pt", DataType::Int(IntType::new()))
+        .options(options);
+    if partitioned {
+        schema = schema.partition_keys(vec!["pt"]);
+    }
+    Table::new(
+        FileIOBuilder::new("memory").build().unwrap(),
+        Identifier::new("default", "indexes"),
+        location,
+        TableSchema::new(0, &schema.build().unwrap()),
+        None,
+    )
+}
+
+async fn append(table: &Table, ids: Vec<i32>, names: Vec<Option<&str>>, pts: 
Vec<Option<i32>>) {
+    let batch = RecordBatch::try_new(
+        Arc::new(ArrowSchema::new(vec![
+            Field::new("id", ArrowType::Int32, true),
+            Field::new("name", ArrowType::Utf8, true),
+            Field::new("pt", ArrowType::Int32, true),
+        ])),
+        vec![
+            Arc::new(Int32Array::from(ids)),
+            Arc::new(StringArray::from(names)),
+            Arc::new(Int32Array::from(pts)),
+        ],
+    )
+    .unwrap();
+    let mut writer = TableWrite::new(table, "data".into()).unwrap();
+    writer.write_arrow_batch(&batch).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    TableCommit::new(table.clone(), "data".into())
+        .commit(messages)
+        .await
+        .unwrap();
+}
+
+async fn read_ids(table: &Table, predicate: Predicate) -> Vec<i32> {
+    let mut builder = table.new_read_builder();
+    builder
+        .with_filter(predicate)
+        .with_projection(&["id"])
+        .unwrap();
+    let plan = builder.new_scan().plan().await.unwrap();
+    let batches = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    let mut ids = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect::<Vec<_>>();
+    ids.sort_unstable();
+    ids
+}
+
+async fn update_name(table: &Table, value: &str) -> Vec<CommitMessage> {
+    let batch = RecordBatch::try_new(
+        Arc::new(ArrowSchema::new(vec![
+            Field::new("_ROW_ID", ArrowType::Int64, false),
+            Field::new("name", ArrowType::Utf8, true),
+        ])),
+        vec![
+            Arc::new(Int64Array::from(vec![0])),
+            Arc::new(StringArray::from(vec![value])),
+        ],
+    )
+    .unwrap();
+    table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .update_by_arrow_with_row_id(vec![batch])
+        .await
+        .unwrap()
+}
+
+#[tokio::test]
+async fn staged_indexes_reject_target_column_changes_and_preserve_files() {
+    for kind in ["btree", "bitmap"] {
+        for same_commit in [false, true] {
+            let table = table(false, false);
+            append(
+                &table,
+                vec![1, 2],
+                vec![Some("a"), Some("b")],
+                vec![None; 2],
+            )
+            .await;
+            let mut builder = table.new_sorted_global_index_build_builder();
+            builder.with_index_column("name").with_index_type(kind);
+            let mut messages = builder.build().await.unwrap();
+            let file = messages[0].new_index_files[0].clone();
+            let updates = update_name(&table, "changed").await;
+            let commit = TableCommit::new(table.clone(), "publish".into());
+            if same_commit {
+                messages.extend(updates);
+            } else {
+                commit.commit(updates).await.unwrap();
+            }
+            let before = table
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap()
+                .id();
+            assert!(commit
+                .commit(messages)
+                .await
+                .unwrap_err()
+                .to_string()
+                .contains("Global index source conflict"));
+            assert_eq!(
+                table
+                    .snapshot_manager()
+                    .get_latest_snapshot()
+                    .await
+                    .unwrap()
+                    .unwrap()
+                    .id(),
+                before
+            );
+            assert!(table
+                .file_io()
+                .exists(&format!("{}/index/{}", table.location(), 
file.file_name))
+                .await
+                .unwrap());
+        }
+    }
+}
+
+#[tokio::test]
+async fn 
staged_indexes_reject_deleted_partial_columns_and_removed_source_ranges() {
+    for kind in ["btree", "bitmap"] {
+        for remove_all in [false, true] {
+            let table = table(false, false);
+            append(
+                &table,
+                vec![1, 2],
+                vec![Some("a"), Some("b")],
+                vec![None; 2],
+            )
+            .await;
+            let updates = update_name(&table, "changed").await;
+            let commit = TableCommit::new(table.clone(), "update".into());
+            commit.commit(updates.clone()).await.unwrap();
+            let mut builder = table.new_sorted_global_index_build_builder();
+            builder.with_index_column("name").with_index_type(kind);
+            let messages = builder.build().await.unwrap();
+            if remove_all {
+                commit.overwrite(vec![], None).await.unwrap();
+            } else {
+                let deletions = updates
+                    .into_iter()
+                    .map(|update| {
+                        let mut message =
+                            CommitMessage::new(update.partition, 
update.bucket, vec![]);
+                        message.deleted_files = update.new_files;
+                        message
+                    })
+                    .collect();
+                commit.commit(deletions).await.unwrap();
+            }
+            let error = commit.commit(messages).await.unwrap_err().to_string();
+            assert!(
+                error.contains(if remove_all {
+                    "Global index row ID existence conflict"
+                } else {
+                    "Global index source conflict"
+                }),
+                "{error}"
+            );
+        }
+    }
+}
+
+#[tokio::test]
+async fn staged_index_survives_append_and_unrelated_column_update() {
+    let table = table(false, false);
+    append(
+        &table,
+        vec![1, 2],
+        vec![Some("a"), Some("b")],
+        vec![None; 2],
+    )
+    .await;
+    let mut builder = table.new_sorted_global_index_build_builder();
+    builder.with_index_column("name");
+    let messages = builder.build().await.unwrap();
+    assert_eq!(
+        crate::spec::DataEvolutionIndexSourceMeta::deserialize(
+            messages[0].new_index_files[0]
+                .global_index_meta
+                .as_ref()
+                .unwrap()
+                .source_meta
+                .as_ref()
+                .unwrap()
+        )
+        .unwrap()
+        .scan_snapshot_id(),
+        1
+    );
+    let batch = RecordBatch::try_new(
+        Arc::new(ArrowSchema::new(vec![
+            Field::new("_ROW_ID", ArrowType::Int64, false),
+            Field::new("id", ArrowType::Int32, true),
+        ])),
+        vec![
+            Arc::new(Int64Array::from(vec![0])),
+            Arc::new(Int32Array::from(vec![10])),
+        ],
+    )
+    .unwrap();
+    let updates = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .update_by_arrow_with_row_id(vec![batch])
+        .await
+        .unwrap();
+    let commit = TableCommit::new(table.clone(), "data".into());
+    commit.commit(updates).await.unwrap();
+    append(&table, vec![3], vec![Some("a")], vec![None]).await;
+    commit.commit(messages).await.unwrap();
+    let predicate = PredicateBuilder::new(table.schema().fields())
+        .equal("name", Datum::String("a".into()))
+        .unwrap();
+    assert_eq!(read_ids(&table, predicate).await, vec![3, 10]);
+}
+
+#[tokio::test]
+async fn equivalent_empty_partition_encodings_share_source_checks() {
+    for partition in [
+        vec![],
+        vec![0, 0, 0, 0],
+        crate::spec::EMPTY_SERIALIZED_ROW.to_vec(),
+    ] {
+        for changed in [false, true] {
+            let table = table(false, false);
+            append(&table, vec![1], vec![Some("a")], vec![None]).await;
+            let mut builder = table.new_sorted_global_index_build_builder();
+            builder.with_index_column("name");
+            let mut messages = builder.build().await.unwrap();
+            for message in &mut messages {
+                message.partition = partition.clone();
+            }
+            let commit = TableCommit::new(table.clone(), "publish".into());
+            if changed {
+                commit
+                    .commit(update_name(&table, "changed").await)
+                    .await
+                    .unwrap();
+                assert!(commit
+                    .commit(messages)
+                    .await
+                    .unwrap_err()
+                    .to_string()
+                    .contains("Global index source conflict"));
+            } else {
+                commit.commit(messages).await.unwrap();
+                let predicate = PredicateBuilder::new(table.schema().fields())
+                    .equal("name", Datum::String("a".into()))
+                    .unwrap();
+                assert_eq!(read_ids(&table, predicate).await, vec![1]);
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn prepared_files_are_invisible_until_explicit_commit() {
+    for kind in ["btree", "bitmap"] {
+        let table = table(false, false);
+        append(
+            &table,
+            vec![3, 1, 2, 4],
+            vec![Some("c"), Some("a"), None, Some("a")],
+            vec![None; 4],
+        )
+        .await;
+        let before = table
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let mut builder = table.new_sorted_global_index_build_builder();
+        builder.with_index_column("name").with_index_type(kind);
+        let messages = builder.build().await.unwrap();
+        assert_eq!(messages.len(), 2);
+        assert!(messages
+            .iter()
+            .all(|m| m.new_files.is_empty() && m.new_index_files.len() == 1));
+        let after_build = table
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(before.id(), after_build.id());
+        assert!(after_build.index_manifest().is_none());
+        for message in &messages {
+            let file = &message.new_index_files[0];
+            assert_eq!(file.index_type, kind);
+            assert_eq!(file.row_count, 2);
+            assert!(table
+                .file_io()
+                .exists(&format!("{}/index/{}", table.location(), 
file.file_name))
+                .await
+                .unwrap());
+            assert_eq!(
+                CommitMessage::deserialize(14, &message.serialize().unwrap())
+                    .unwrap()
+                    .new_index_files,
+                message.new_index_files
+            );
+        }
+        TableCommit::new(table.clone(), "publish".into())
+            .commit(messages)
+            .await
+            .unwrap();
+        let after = table
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(after.id(), before.id() + 1);
+        assert!(after.index_manifest().is_some());
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .equal("name", Datum::String("a".into()))
+            .unwrap();
+        assert_eq!(read_ids(&table, predicate).await, vec![1, 4]);
+        assert!(builder.build().await.unwrap().is_empty());
+        assert_eq!(builder.execute().await.unwrap(), 0);
+        assert_eq!(
+            table
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap()
+                .id(),
+            after.id()
+        );
+    }
+}
+
+#[tokio::test]
+async fn caller_can_abort_prepared_files_without_changing_snapshot() {
+    for kind in ["btree", "bitmap"] {
+        let table = table(false, true);
+        append(&table, vec![1, 2], vec![Some("a"), None], vec![None; 2]).await;
+        let mut builder = table.new_sorted_global_index_build_builder();
+        builder.with_index_column("name").with_index_type(kind);
+        let messages = builder.build().await.unwrap();
+        let path = messages[0].new_index_files[0]
+            .external_path
+            .as_ref()
+            .unwrap();
+        assert!(path.starts_with(
+            table
+                .schema()
+                .options()
+                .get("global-index.external-path")
+                .unwrap()
+        ));
+        assert!(table.file_io().exists(path).await.unwrap());
+        assert!(!table
+            .file_io()
+            .exists(&format!(
+                "{}/index/{}",
+                table.location(),
+                messages[0].new_index_files[0].file_name
+            ))
+            .await
+            .unwrap());
+        TableCommit::new(table.clone(), "discard".into())
+            .abort(&messages)
+            .await
+            .unwrap();
+        assert!(!table.file_io().exists(path).await.unwrap());
+        assert_eq!(
+            table
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap()
+                .id(),
+            1
+        );
+        let replacements = builder.build().await.unwrap();
+        TableCommit::new(table.clone(), "publish".into())
+            .commit(replacements)
+            .await
+            .unwrap();
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .is_null("name")
+            .unwrap();
+        assert_eq!(read_ids(&table, predicate).await, vec![2]);
+    }
+}
+
+#[tokio::test]
+async fn partition_builds_only_cover_selected_partitions_and_new_rows() {
+    for kind in ["btree", "bitmap"] {
+        let table = table(true, false);
+        append(
+            &table,
+            vec![1, 2, 3],
+            vec![Some("a"), Some("b"), Some("c")],
+            vec![Some(0), Some(1), None],
+        )
+        .await;
+        let predicates = PredicateBuilder::new(table.schema().fields());
+        let selected = predicates.equal("pt", Datum::Int(1)).unwrap();
+        let mut builder = table.new_sorted_global_index_build_builder();
+        builder
+            .with_index_column("id")
+            .with_index_type(kind)
+            .with_partition_filter(selected.clone())
+            .unwrap();
+        let first = builder.build().await.unwrap();
+        assert_eq!(
+            first
+                .iter()
+                .map(|m| m.new_index_files.iter().map(|f| 
f.row_count).sum::<i64>())
+                .sum::<i64>(),
+            1
+        );
+        TableCommit::new(table.clone(), "partition-one".into())
+            .commit(first)
+            .await
+            .unwrap();
+        assert!(builder.build().await.unwrap().is_empty());
+        append(
+            &table,
+            vec![4, 5],
+            vec![Some("d"), Some("e")],
+            vec![Some(1), Some(0)],
+        )
+        .await;
+        assert_eq!(builder.execute().await.unwrap(), 1);
+        assert!(builder.build().await.unwrap().is_empty());
+
+        let mut all = table.new_sorted_global_index_build_builder();
+        all.with_index_column("id").with_index_type(kind);
+        let remaining = all.build().await.unwrap();
+        assert_eq!(
+            remaining
+                .iter()
+                .flat_map(|m| &m.new_index_files)
+                .map(|f| f.row_count)
+                .sum::<i64>(),
+            3
+        );
+        TableCommit::new(table.clone(), "all-partitions".into())
+            .commit(remaining)
+            .await
+            .unwrap();
+        assert!(all.build().await.unwrap().is_empty());
+        assert_eq!(read_ids(&table, selected).await, vec![2, 4]);
+    }
+}
+
+#[tokio::test]
+async fn empty_and_disjoint_partition_builds_publish_nothing() {
+    let table = table(true, false);
+    let mut builder = table.new_sorted_global_index_build_builder();
+    builder.with_index_column("id");
+    assert!(builder.build().await.unwrap().is_empty());
+    assert_eq!(builder.execute().await.unwrap(), 0);
+    assert!(table
+        .snapshot_manager()
+        .get_latest_snapshot()
+        .await
+        .unwrap()
+        .is_none());
+    append(&table, vec![1], vec![Some("a")], vec![Some(1)]).await;
+    let predicates = PredicateBuilder::new(table.schema().fields());
+    for value in [1, 2] {
+        builder
+            .with_partition_filter(predicates.equal("pt", 
Datum::Int(value)).unwrap())
+            .unwrap();
+    }
+    assert!(builder.build().await.unwrap().is_empty());
+    assert_eq!(builder.execute().await.unwrap(), 0);
+    assert_eq!(
+        table
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap()
+            .id(),
+        1
+    );
+    let data_filter = predicates.equal("id", Datum::Int(1)).unwrap();
+    assert!(builder.with_partition_filter(data_filter).is_err());
+    let unpartitioned = self::table(false, false);
+    let filter = PredicateBuilder::new(unpartitioned.schema().fields())
+        .is_null("pt")
+        .unwrap();
+    assert!(unpartitioned
+        .new_sorted_global_index_build_builder()
+        .with_partition_filter(filter)
+        .is_err());
+}
diff --git 
a/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
index fdacd5b7..683ccc31 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
@@ -85,19 +85,24 @@ impl SortedGlobalIndexBuildBuilder<'_> {
             sort_index_rows(&mut rows, &cmp)?;
         }
 
-        self.table
-            .file_io()
-            .mkdirs(&format!(
-                "{}/{INDEX_DIR}/",
-                self.table.location().trim_end_matches('/')
-            ))
-            .await?;
         let file_name = format!("{index_type}-global-index-{}.index", 
uuid::Uuid::new_v4());
-        let index_path = format!(
-            "{}/{INDEX_DIR}/{}",
-            self.table.location().trim_end_matches('/'),
-            file_name
-        );
+        // Global index placement is a table property, as in Java 
GlobalIndexFileFactory.
+        let external_path = 
super::super::external_path::new_index_external_path(
+            self.table.schema().options(),
+            false,
+            "",
+            &file_name,
+        )?;
+        let index_path = external_path.clone().unwrap_or_else(|| {
+            format!(
+                "{}/{INDEX_DIR}/{}",
+                self.table.location().trim_end_matches('/'),
+                file_name,
+            )
+        });
+        if let Some((directory, _)) = index_path.rsplit_once('/') {
+            self.table.file_io().mkdirs(directory).await?;
+        }
         let write_result: Result<(u64, Vec<u8>, i64)> = async {
             let output = self.table.file_io().new_output(&index_path)?;
             let writer = output.writer().await?;
@@ -251,7 +256,7 @@ impl SortedGlobalIndexBuildBuilder<'_> {
             file_size,
             row_count,
             deletion_vectors_ranges: None,
-            external_path: None,
+            external_path,
             global_index_meta: Some(GlobalIndexMeta {
                 row_range_start: shard.row_range_start,
                 row_range_end: shard.row_range_end,
@@ -263,7 +268,9 @@ impl SortedGlobalIndexBuildBuilder<'_> {
                         .map(|field| field.id())
                         .collect()
                 }),
-                source_meta: None,
+                source_meta: Some(
+                    
crate::spec::DataEvolutionIndexSourceMeta::new(shard.snapshot_id)?.serialize(),
+                ),
                 index_meta: Some(index_meta),
             }),
         })
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index c508694d..698bea47 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -48,6 +48,7 @@ const BATCH_COMMIT_IDENTIFIER: i64 = i64::MAX;
 /// Java RollingFileWriter.CHECK_ROLLING_RECORD_CNT.
 const CHECK_ROLLING_RECORD_COUNT: usize = 1000;
 
+mod global_index_source_check;
 mod row_tracking;
 
 const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
@@ -1743,6 +1744,8 @@ impl TableCommit {
 
                 let previous =
                     Self::read_prev_index_entries(file_io, &manifest_dir, 
latest_snapshot).await?;
+                self.check_global_index_sources(latest_snapshot, entries, 
new_index_entries)
+                    .await?;
                 let mut index_entries = new_index_entries.clone();
                 index_entries.extend(self.global_index_update_entries(
                     &previous,
@@ -1804,6 +1807,8 @@ impl TableCommit {
 
                 let previous =
                     Self::read_prev_index_entries(file_io, &manifest_dir, 
latest_snapshot).await?;
+                self.check_global_index_sources(latest_snapshot, &entries, 
&new_index_entries)
+                    .await?;
                 let mut all = previous.clone();
                 match partition_filter.as_ref() {
                     None => all.clear(),
diff --git a/crates/paimon/src/table/table_commit/global_index_source_check.rs 
b/crates/paimon/src/table/table_commit/global_index_source_check.rs
new file mode 100644
index 00000000..e0d83a28
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/global_index_source_check.rs
@@ -0,0 +1,186 @@
+// 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.
+
+//! Validate unpublished global indexes against their source data on every 
commit attempt.
+
+use super::*;
+use crate::spec::DataEvolutionIndexSourceMeta;
+use crate::table::{merge_row_ranges, RowRange};
+
+impl TableCommit {
+    pub(super) async fn check_global_index_sources(
+        &self,
+        latest_snapshot: &Option<Snapshot>,
+        data_entries: &[ManifestEntry],
+        index_entries: &[IndexManifestEntry],
+    ) -> Result<()> {
+        if !self.data_evolution_enabled {
+            return Ok(());
+        }
+        let additions = index_entries
+            .iter()
+            .filter(|entry| {
+                entry.kind == FileKind::Add && 
entry.index_file.global_index_meta.is_some()
+            })
+            .cloned()
+            .collect::<Vec<_>>();
+        if additions.is_empty() {
+            return Ok(());
+        }
+        let partition_fields = self.table.schema().partition_fields();
+        let filter = if partition_fields.is_empty() {
+            None
+        } else {
+            Some(PartitionFilter::from_partition_set(
+                additions
+                    .iter()
+                    .map(|entry| entry.partition.clone())
+                    .collect(),
+                &partition_fields,
+            )?)
+        };
+        let mut current = self
+            .scan_snapshot_entries(latest_snapshot, filter.as_ref())
+            .await?;
+        current.extend_from_slice(data_entries);
+        let current = merge_active_entries(current);
+        let mut ranges: HashMap<PartitionBucketKey, Vec<RowRange>> = 
HashMap::new();
+        for entry in current {
+            if let Some((start, end)) = entry.file().row_id_range() {
+                ranges
+                    .entry((partition_key(entry.partition()), entry.bucket()))
+                    .or_default()
+                    .push(RowRange::new(start, end));
+            }
+        }
+        let ranges = ranges
+            .into_iter()
+            .map(|(key, ranges)| (key, merge_row_ranges(ranges)))
+            .collect::<HashMap<_, _>>();
+        let mut sources = Vec::new();
+        for entry in &additions {
+            let meta = entry.index_file.global_index_meta.as_ref().unwrap();
+            if !ranges
+                .get(&(partition_key(&entry.partition), entry.bucket))
+                .is_some_and(|ranges| {
+                    ranges.iter().any(|range| {
+                        range.from() <= meta.row_range_start && 
meta.row_range_end <= range.to()
+                    })
+                })
+            {
+                return Err(index_conflict(format!(
+                    "Global index row ID existence conflict: index file '{}' 
is not fully covered by current data files.",
+                    entry.index_file.file_name
+                )));
+            }
+            if 
DataEvolutionIndexSourceMeta::is_data_evolution_meta(meta.source_meta.as_deref())
 {
+                let source = DataEvolutionIndexSourceMeta::deserialize(
+                    meta.source_meta.as_deref().unwrap(),
+                )?;
+                let source_id = source.scan_snapshot_id();
+                let latest = latest_snapshot.as_ref().ok_or_else(|| {
+                    index_conflict("Global index source conflict: no snapshot 
exists.")
+                })?;
+                if source_id > latest.id() {
+                    return Err(index_conflict("Global index source conflict: 
source snapshot is newer than the latest snapshot."));
+                }
+                // Missing/expired snapshots cannot prove that the indexed 
values are unchanged.
+                if source_id != latest.id() {
+                    self.snapshot_manager.get_snapshot(source_id).await?;
+                }
+                sources.push((source_id, entry.clone()));
+            }
+        }
+        if sources.is_empty() {
+            return Ok(());
+        }
+        self.check_index_column_changes(&sources, data_entries, None)
+            .await?;
+        let earliest = sources.iter().map(|(id, _)| *id).min().unwrap();
+        let latest = latest_snapshot.as_ref().unwrap();
+        for id in earliest + 1..=latest.id() {
+            let snapshot = if id == latest.id() {
+                latest.clone()
+            } else {
+                self.snapshot_manager.get_snapshot(id).await?
+            };
+            if snapshot.commit_kind() == &CommitKind::COMPACT {
+                continue;
+            }
+            let changes = self.read_delta_entries(filter.as_ref(), 
&snapshot).await?;
+            self.check_index_column_changes(&sources, &changes, Some(id))
+                .await?;
+        }
+        Ok(())
+    }
+
+    async fn check_index_column_changes(
+        &self,
+        sources: &[(i64, IndexManifestEntry)],
+        changes: &[ManifestEntry],
+        snapshot_id: Option<i64>,
+    ) -> Result<()> {
+        for change in changes {
+            let Some((start, end)) = change.file().row_id_range() else {
+                continue;
+            };
+            let overlapping = sources
+                .iter()
+                .filter(|(source_id, index)| {
+                    let meta = 
index.index_file.global_index_meta.as_ref().unwrap();
+                    snapshot_id.is_none_or(|id| id > *source_id)
+                        && same_index_partition(&index.partition, 
change.partition())
+                        && index.bucket == change.bucket()
+                        && ranges_overlap(start, end, meta.row_range_start, 
meta.row_range_end)
+                })
+                .map(|(_, index)| index)
+                .collect::<Vec<_>>();
+            if overlapping.is_empty() {
+                continue;
+            }
+            let written_fields = self.write_field_ids(change.file()).await?;
+            for index in overlapping {
+                let meta = 
index.index_file.global_index_meta.as_ref().unwrap();
+                if std::iter::once(&meta.index_field_id)
+                    .chain(meta.extra_field_ids.iter().flatten())
+                    .any(|id| written_fields.contains(id))
+                {
+                    return Err(index_conflict(format!(
+                        "Global index source conflict: indexed values changed 
after building index file '{}'.",
+                        index.index_file.file_name
+                    )));
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+fn index_conflict(message: impl Into<String>) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: message.into(),
+        source: None,
+    }
+}
+
+fn partition_key(partition: &[u8]) -> Vec<u8> {
+    if is_empty_partition(partition) {
+        Vec::new()
+    } else {
+        partition.to_vec()
+    }
+}
diff --git a/crates/paimon/src/table/table_commit/row_tracking_tests.rs 
b/crates/paimon/src/table/table_commit/row_tracking_tests.rs
index 598ee627..16f89ccb 100644
--- a/crates/paimon/src/table/table_commit/row_tracking_tests.rs
+++ b/crates/paimon/src/table/table_commit/row_tracking_tests.rs
@@ -423,19 +423,22 @@ async fn 
row_tracking_ignore_index_update_preserves_index_metadata() {
     let commit = TableCommit::new(table, "test-user".into());
     let mut file = test_data_file("initial.parquet", 10);
     file.file_source = Some(0);
-    let mut seed = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0, 
vec![file]);
-    let index = test_global_index_file("global-name.index", 1, 0, 9);
-    seed.new_index_files.push(index.clone());
+    let seed = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0, vec![file]);
     commit.commit(vec![seed]).await.unwrap();
+    // Global indexes reference row IDs assigned by a completed data commit.
+    let mut indexes = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0, 
vec![]);
+    let index = test_global_index_file("global-name.index", 1, 0, 9);
+    indexes.new_index_files.push(index.clone());
+    commit.commit(vec![indexes]).await.unwrap();
     let mut partial = test_data_file("updated.parquet", 10);
     partial.file_source = Some(0);
     partial.first_row_id = Some(0);
     partial.write_cols = Some(vec!["name".into()]);
     let mut update = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0, 
vec![partial]);
-    update.check_from_snapshot = Some(1);
+    update.check_from_snapshot = Some(2);
     commit.commit(vec![update]).await.unwrap();
     let snapshot = latest_snapshot(&io, path).await.unwrap();
-    assert_eq!(snapshot.id(), 2);
+    assert_eq!(snapshot.id(), 3);
     assert_eq!(snapshot.next_row_id(), Some(10));
     let index_entries = IndexManifest::read(
         &io,
diff --git a/crates/paimon/src/table/vector_search_common.rs 
b/crates/paimon/src/table/vector_search_common.rs
index 321bd914..10dcab6f 100644
--- a/crates/paimon/src/table/vector_search_common.rs
+++ b/crates/paimon/src/table/vector_search_common.rs
@@ -18,9 +18,7 @@
 //! Shared vector-search option validation, index I/O helpers, and result 
ordering.
 
 use crate::lumina::is_lumina_index_type;
-use crate::spec::{
-    field_idx_to_partition_idx, CoreOptions, DataField, Predicate, 
ROW_ID_FIELD_NAME,
-};
+use crate::spec::{CoreOptions, DataField, Predicate, ROW_ID_FIELD_NAME};
 use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN, 
SEARCH_SCORE_COLUMN};
 use crate::table::read_builder::resolve_projected_fields;
 use crate::table::Table;
@@ -30,20 +28,7 @@ use arrow_array::{Int64Array, RecordBatch};
 use arrow_select::interleave::interleave_record_batch;
 use std::collections::HashMap;
 
-/// Explicit partition filters use table-field indices, like scalar filters.
-/// Reject mixed predicates rather than silently discarding their data 
conjuncts.
-pub(super) fn validate_partition_filter(table: &Table, filter: &Predicate) -> 
crate::Result<()> {
-    let mapping =
-        field_idx_to_partition_idx(table.schema().fields(), 
table.schema().partition_keys());
-    if table.schema().partition_keys().is_empty() || 
!filter.references_only_mapped_fields(&mapping)
-    {
-        return Err(crate::Error::ConfigInvalid {
-            message: "Partition filter must reference only partition keys of a 
partitioned table"
-                .to_string(),
-        });
-    }
-    Ok(())
-}
+pub(super) use super::partition_filter::validate_partition_filter;
 
 pub(super) fn add_filter(current: &mut Option<Predicate>, filter: Predicate) {
     *current = Some(match current.take() {

Reply via email to