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 5ab393d  feat(core): incremental global index build for btree, lumina, 
and vindex (#479)
5ab393d is described below

commit 5ab393d80a20084cd20d7cbad2617401ba8b69b7
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Jul 8 09:21:32 2026 +0800

    feat(core): incremental global index build for btree, lumina, and vindex 
(#479)
---
 .../src/table/btree_global_index_build_builder.rs  | 567 ++++++++++++++++---
 .../paimon/src/table/global_index_build_common.rs  | 129 +++++
 crates/paimon/src/table/global_index_scanner.rs    |  39 +-
 .../paimon/src/table/lumina_index_build_builder.rs | 515 ++++++++++++++---
 crates/paimon/src/table/mod.rs                     |   1 +
 crates/paimon/src/table/source.rs                  | 139 +++++
 crates/paimon/src/table/table_commit.rs            | 137 +++++
 .../paimon/src/table/vindex_index_build_builder.rs | 630 ++++++++++++++++++---
 8 files changed, 1909 insertions(+), 248 deletions(-)

diff --git a/crates/paimon/src/table/btree_global_index_build_builder.rs 
b/crates/paimon/src/table/btree_global_index_build_builder.rs
index 2f242ca..5725179 100644
--- a/crates/paimon/src/table/btree_global_index_build_builder.rs
+++ b/crates/paimon/src/table/btree_global_index_build_builder.rs
@@ -22,8 +22,9 @@ use super::global_index_types::{
 use crate::btree::{make_key_comparator, serialize_datum, BTreeIndexWriter, 
BlockCompressionType};
 use crate::spec::{
     bucket_dir_name, extract_datum_from_arrow, BinaryRow, CoreOptions, 
DataField, DataFileMeta,
-    DataType, FileKind, GlobalIndexMeta, IndexFileMeta, IndexManifest, 
ROW_ID_FIELD_NAME,
+    DataType, FileKind, GlobalIndexMeta, IndexFileMeta, ROW_ID_FIELD_NAME,
 };
+use crate::table::source::exclude_row_ranges;
 use crate::table::source::is_data_evolution_normal_file;
 use crate::table::stats_filter::group_by_overlapping_row_id;
 use crate::table::{
@@ -109,6 +110,15 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
             .with_scan_all_files()
             .plan_manifest_entries(&snapshot)
             .await?;
+        let indexed = 
crate::table::global_index_build_common::indexed_row_ranges(
+            self.table,
+            snapshot.index_manifest(),
+            BTREE_INDEX_TYPE,
+            index_field.id(),
+            None, // single-column build; no extra fields today
+        )
+        .await?;
+
         let shards = plan_btree_shards(
             self.table.location(),
             self.table.schema().partition_keys(),
@@ -117,16 +127,22 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
             snapshot.id(),
             manifest_entries,
             records_per_range,
+            &indexed,
         )?;
         if shards.is_empty() {
             return Ok(0);
         }
 
-        validate_existing_index_overlap(
+        
crate::table::global_index_build_common::validate_existing_index_overlap(
             self.table,
             snapshot.index_manifest(),
+            BTREE_INDEX_TYPE,
             index_field.id(),
-            &shards,
+            None,
+            &shards
+                .iter()
+                .map(|shard| RowRange::new(shard.row_range_start, 
shard.row_range_end))
+                .collect::<Vec<_>>(),
         )
         .await?;
 
@@ -369,6 +385,7 @@ fn is_btree_supported_data_type(data_type: &DataType) -> 
bool {
     )
 }
 
+#[allow(clippy::too_many_arguments)]
 fn plan_btree_shards(
     table_location: &str,
     partition_keys: &[String],
@@ -377,6 +394,7 @@ fn plan_btree_shards(
     snapshot_id: i64,
     entries: Vec<crate::spec::ManifestEntry>,
     records_per_range: i64,
+    indexed: &[RowRange],
 ) -> Result<Vec<BTreeGlobalIndexShard>> {
     if records_per_range <= 0 {
         return Err(Error::DataInvalid {
@@ -426,24 +444,30 @@ fn plan_btree_shards(
         let normal_groups = group_normal_file_ranges(files)?;
         for group in normal_groups {
             let (coverage_start, coverage_end) = 
normal_coverage_range(&group.files)?;
-            let start_range = coverage_start / records_per_range;
-            let end_range = coverage_end / records_per_range;
-            for range_id in start_range..=end_range {
-                let range_start = range_id * records_per_range;
-                let range_end = range_start + records_per_range - 1;
-                let row_range_start = coverage_start.max(range_start);
-                let row_range_end = coverage_end.min(range_end);
-                result.push(BTreeGlobalIndexShard {
-                    partition: partition.clone(),
-                    partition_bytes: partition_bytes.clone(),
-                    files: group.files.clone(),
-                    row_range_start,
-                    row_range_end,
-                    snapshot_id,
-                    source_bucket,
-                    total_buckets,
-                    bucket_path: bucket_path.clone(),
-                });
+            let build_segments =
+                exclude_row_ranges(&[RowRange::new(coverage_start, 
coverage_end)], indexed);
+            for seg in build_segments {
+                let seg_start = seg.from();
+                let seg_end = seg.to();
+                let start_range = seg_start / records_per_range;
+                let end_range = seg_end / records_per_range;
+                for range_id in start_range..=end_range {
+                    let range_start = range_id * records_per_range;
+                    let range_end = range_start + records_per_range - 1;
+                    let row_range_start = seg_start.max(range_start);
+                    let row_range_end = seg_end.min(range_end);
+                    result.push(BTreeGlobalIndexShard {
+                        partition: partition.clone(),
+                        partition_bytes: partition_bytes.clone(),
+                        files: group.files.clone(),
+                        row_range_start,
+                        row_range_end,
+                        snapshot_id,
+                        source_bucket,
+                        total_buckets,
+                        bucket_path: bucket_path.clone(),
+                    });
+                }
             }
         }
     }
@@ -565,51 +589,6 @@ fn bucket_path(
     ))
 }
 
-async fn validate_existing_index_overlap(
-    table: &Table,
-    index_manifest_name: Option<&str>,
-    index_field_id: i32,
-    shards: &[BTreeGlobalIndexShard],
-) -> Result<()> {
-    let Some(index_manifest_name) = index_manifest_name else {
-        return Ok(());
-    };
-    let path = format!(
-        "{}/manifest/{}",
-        table.location().trim_end_matches('/'),
-        index_manifest_name
-    );
-    let entries = IndexManifest::read(table.file_io(), &path).await?;
-    for entry in entries {
-        if entry.kind != FileKind::Add {
-            continue;
-        }
-        let Some(meta) = entry.index_file.global_index_meta else {
-            continue;
-        };
-        if meta.index_field_id != index_field_id {
-            continue;
-        }
-        if shards.iter().any(|shard| {
-            ranges_overlap(
-                meta.row_range_start,
-                meta.row_range_end,
-                shard.row_range_start,
-                shard.row_range_end,
-            )
-        }) {
-            return Err(Error::DataInvalid {
-                message: format!(
-                    "Existing global index file '{}' overlaps requested row 
range for field {}",
-                    entry.index_file.file_name, index_field_id
-                ),
-                source: None,
-            });
-        }
-    }
-    Ok(())
-}
-
 async fn extract_index_rows(
     table: &Table,
     shard: &BTreeGlobalIndexShard,
@@ -786,11 +765,11 @@ mod tests {
     use crate::io::FileIOBuilder;
     use crate::spec::stats::BinaryTableStats;
     use crate::spec::{
-        BinaryType, GlobalIndexSearchMode, IntType, ManifestEntry, 
PredicateBuilder, Schema,
-        TableSchema, VarBinaryType, VarCharType,
+        BinaryType, GlobalIndexSearchMode, IndexManifest, IntType, 
ManifestEntry, PredicateBuilder,
+        Schema, TableSchema, VarBinaryType, VarCharType,
     };
     use crate::table::global_index_scanner::{evaluate_global_index, 
GlobalIndexEvaluation};
-    use crate::table::{SnapshotManager, TableCommit, TableWrite};
+    use crate::table::{merge_row_ranges, SnapshotManager, TableCommit, 
TableWrite};
     use arrow_array::{ArrayRef, Int32Array, Int64Array, StringArray};
     use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
     use chrono::{DateTime, Utc};
@@ -889,6 +868,7 @@ mod tests {
             1,
             entries,
             records_per_range,
+            &[],
         )
     }
 
@@ -1360,15 +1340,74 @@ mod tests {
         assert_eq!(row_ranges, vec![RowRange::new(0, 0), RowRange::new(2, 2)]);
     }
 
+    async fn latest_btree_index_files(table: &Table) -> Vec<IndexFileMeta> {
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let Some(index_manifest_name) = snapshot.index_manifest() else {
+            return Vec::new();
+        };
+        IndexManifest::read(
+            table.file_io(),
+            &snapshot_manager.manifest_path(index_manifest_name),
+        )
+        .await
+        .unwrap()
+        .into_iter()
+        .filter(|entry| {
+            entry.kind == FileKind::Add && entry.index_file.index_type == 
BTREE_INDEX_TYPE
+        })
+        .map(|entry| entry.index_file)
+        .collect()
+    }
+
+    /// Row-id coverage of the committed data files, read back from the data
+    /// manifest (never hard-coded) and merged into contiguous ranges. Mirrors
+    /// how `execute` gathers `manifest_entries` so tests observe the exact
+    /// row-ids the writer assigned.
+    async fn data_row_id_coverage(table: &Table) -> Vec<RowRange> {
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let entries = table
+            .new_read_builder()
+            .new_scan()
+            .with_scan_all_files()
+            .plan_manifest_entries(&snapshot)
+            .await
+            .unwrap();
+        let ranges = entries
+            .iter()
+            .filter(|entry| *entry.kind() == FileKind::Add)
+            .filter_map(|entry| {
+                entry
+                    .file()
+                    .row_id_range()
+                    .map(|(start, end)| RowRange::new(start, end))
+            })
+            .collect::<Vec<_>>();
+        merge_row_ranges(ranges)
+    }
+
+    /// Second build with no new data must be a clean no-op (returns 0), not an
+    /// overlap error. This is the core bug fix: today the second call errors.
     #[tokio::test]
-    async fn test_execute_rejects_existing_overlap_before_commit() {
-        let table_path = "memory:/test_btree_global_index_builder_overlap";
+    async fn second_build_without_new_data_is_noop() {
+        let table_path = "memory:/test_btree_global_index_second_build_noop";
         let table = test_table_with_path(table_path, table_options("10"));
         setup_dirs(&table).await;
 
         let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
         table_write
-            .write_arrow_batch(&data_batch(vec![1, 2], vec!["alice", "bob"]))
+            .write_arrow_batch(&data_batch(vec![1, 2, 3], vec!["alice", "bob", 
"carol"]))
             .await
             .unwrap();
         let messages = table_write.prepare_commit().await.unwrap();
@@ -1377,21 +1416,397 @@ mod tests {
             .await
             .unwrap();
 
-        table
+        let first_built = table
+            .new_btree_global_index_build_builder()
+            .with_index_column("name")
+            .execute()
+            .await
+            .unwrap();
+        assert!(first_built > 0, "first build must index the initial rows");
+
+        let files_after_first = latest_btree_index_files(&table).await;
+        assert!(!files_after_first.is_empty());
+
+        let built = table
+            .new_btree_global_index_build_builder()
+            .with_index_column("name")
+            .execute()
+            .await
+            .unwrap();
+        assert_eq!(built, 0, "fully-indexed table must build nothing on 
re-run");
+
+        let files_after_second = latest_btree_index_files(&table).await;
+        let names_first = files_after_first
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        let names_second = files_after_second
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        assert_eq!(
+            names_first, names_second,
+            "re-run must not add or remove index manifest entries"
+        );
+    }
+
+    /// Build, append new rows, build again -> only the appended row range is
+    /// indexed; the first build's index files are retained untouched 
(append-only).
+    #[tokio::test]
+    async fn incremental_build_indexes_only_new_rows() {
+        let table_path = "memory:/test_btree_global_index_incremental";
+        let table = test_table_with_path(table_path, table_options("10"));
+        setup_dirs(&table).await;
+
+        // Build #1 over rows [0..3).
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&data_batch(vec![1, 2, 3], vec!["alice", "bob", 
"carol"]))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let first_built = table
+            .new_btree_global_index_build_builder()
+            .with_index_column("name")
+            .execute()
+            .await
+            .unwrap();
+        assert!(first_built > 0);
+
+        let first_files = latest_btree_index_files(&table).await;
+        let first_names = first_files
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        let n: i64 = 3;
+
+        // Append a second batch (new row-ids [3..6)).
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&data_batch(vec![4, 5, 6], vec!["dave", "erin", 
"frank"]))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let second_built = table
+            .new_btree_global_index_build_builder()
+            .with_index_column("name")
+            .execute()
+            .await
+            .unwrap();
+        assert!(second_built > 0, "appended rows must be indexed");
+
+        let all_files = latest_btree_index_files(&table).await;
+        let all_names = all_files
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+
+        // Every build-#1 file is still present (append-only, no 
rewrite/delete).
+        assert!(
+            first_names.iter().all(|name| all_names.contains(name)),
+            "build #1 index files must be retained untouched"
+        );
+
+        // Every build-#2 file covers only the appended range [N, ..].
+        let new_files = all_files
+            .iter()
+            .filter(|f| !first_names.contains(&f.file_name))
+            .collect::<Vec<_>>();
+        assert!(!new_files.is_empty(), "build #2 must add new index files");
+        for file in new_files {
+            let meta = file
+                .global_index_meta
+                .as_ref()
+                .expect("global index meta on new btree file");
+            assert!(
+                meta.row_range_start >= n,
+                "new index file range must start at or after {}, got [{}, {}]",
+                n,
+                meta.row_range_start,
+                meta.row_range_end
+            );
+        }
+    }
+
+    /// Regression: first build (no existing index) must equal the pre-change
+    /// full build -- subtraction with empty `indexed` = full coverage.
+    #[tokio::test]
+    async fn first_build_indexes_full_coverage() {
+        let table_path = "memory:/test_btree_global_index_first_full_coverage";
+        let table = test_table_with_path(table_path, table_options("10"));
+        setup_dirs(&table).await;
+
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&data_batch(vec![1, 2, 3], vec!["alice", "bob", 
"carol"]))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let built = table
             .new_btree_global_index_build_builder()
             .with_index_column("name")
             .execute()
             .await
             .unwrap();
+        assert_eq!(
+            built, 1,
+            "first build must index the full coverage in one shard"
+        );
 
-        let err = table
+        let files = latest_btree_index_files(&table).await;
+        assert_eq!(files.len(), 1);
+        let meta = files[0]
+            .global_index_meta
+            .as_ref()
+            .expect("global index meta");
+        assert_eq!(meta.row_range_start, 0);
+        assert_eq!(meta.row_range_end, 2);
+    }
+
+    /// Grid boundary (spec edge 4): with `records-per-range = 4`, an appended
+    /// gap that spans several grid cells must be split so each new index 
file's
+    /// range stays inside one cell, the ranges are contiguous, and together
+    /// they exactly cover the gap. Row-ids are read back from the manifests,
+    /// never hard-coded.
+    #[tokio::test]
+    async fn incremental_build_splits_gap_across_records_per_range_grid() {
+        const RPR: i64 = 4;
+        let table_path = "memory:/test_btree_global_index_grid_boundary";
+        let table = test_table_with_path(table_path, table_options("4"));
+        setup_dirs(&table).await;
+
+        // Build #1 over an initial batch (row-ids the writer assigns).
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&data_batch(vec![1, 2, 3], vec!["alice", "bob", 
"carol"]))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let first_built = table
             .new_btree_global_index_build_builder()
             .with_index_column("name")
             .execute()
             .await
-            .expect_err("second build should overlap existing index");
+            .unwrap();
+        assert!(first_built > 0, "first build must index the initial rows");
+
+        // Row range already covered by build #1 (read back, not hard-coded).
+        let first_index_files = latest_btree_index_files(&table).await;
+        let indexed_before = merge_row_ranges(
+            first_index_files
+                .iter()
+                .filter_map(|f| f.global_index_meta.as_ref())
+                .map(|m| RowRange::new(m.row_range_start, m.row_range_end))
+                .collect(),
+        );
+        assert_eq!(
+            indexed_before.len(),
+            1,
+            "build #1 should cover one contiguous range"
+        );
+        let gap_start = indexed_before[0].to() + 1;
+        let before_names = first_index_files
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+
+        // Append rows so the new gap crosses records_per_range (=4) 
boundaries.
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&data_batch(
+                vec![4, 5, 6, 7, 8, 9, 10],
+                vec!["d", "e", "f", "g", "h", "i", "j"],
+            ))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        // Total data coverage read back from the data manifest.
+        let coverage = data_row_id_coverage(&table).await;
+        assert_eq!(
+            coverage.len(),
+            1,
+            "appended data must be contiguous with build #1"
+        );
+        let gap_end = coverage[0].to();
         assert!(
-            matches!(err, Error::DataInvalid { message, .. } if 
message.contains("overlaps requested row range"))
+            gap_end - gap_start + 1 > RPR,
+            "gap [{gap_start}, {gap_end}] must span more than one 
records_per_range cell"
+        );
+
+        let second_built = table
+            .new_btree_global_index_build_builder()
+            .with_index_column("name")
+            .execute()
+            .await
+            .unwrap();
+        assert!(second_built > 0, "appended rows must be indexed");
+
+        // Only the newly written index files (build #1 files are retained).
+        let mut new_metas = latest_btree_index_files(&table)
+            .await
+            .into_iter()
+            .filter(|f| !before_names.contains(&f.file_name))
+            .filter_map(|f| f.global_index_meta)
+            .map(|m| (m.row_range_start, m.row_range_end))
+            .collect::<Vec<_>>();
+        new_metas.sort();
+        assert!(!new_metas.is_empty(), "build #2 must add new index files");
+
+        // (a) Each range lies within a single grid cell: no multiple of RPR is
+        //     strictly interior, i.e. start and end share the same cell index.
+        for (start, end) in &new_metas {
+            assert!(end >= start, "range must be non-empty: [{start}, {end}]");
+            assert_eq!(
+                start / RPR,
+                end / RPR,
+                "range [{start}, {end}] straddles a records_per_range boundary"
+            );
+        }
+        // (b) Contiguous with no gaps or overlaps.
+        for pair in new_metas.windows(2) {
+            assert_eq!(
+                pair[1].0,
+                pair[0].1 + 1,
+                "ranges must be contiguous: {:?} then {:?}",
+                pair[0],
+                pair[1]
+            );
+        }
+        // (c) Together they exactly cover the appended gap [gap_start, 
gap_end].
+        assert_eq!(
+            new_metas.first().unwrap().0,
+            gap_start,
+            "coverage must start at the gap start"
+        );
+        assert_eq!(
+            new_metas.last().unwrap().1,
+            gap_end,
+            "coverage must end at the gap end"
         );
     }
+
+    /// Hole splitting (spec edge 5) at build level: a mid-coverage indexed 
range
+    /// (constructed directly, as the drop-builder tests build 
`GlobalIndexMeta`
+    /// entries) must carve the data coverage into two build segments, one on
+    /// each side, and the hole itself must not be re-indexed.
+    #[tokio::test]
+    async fn incremental_build_splits_gap_around_mid_coverage_indexed_hole() {
+        let table_path = "memory:/test_btree_global_index_mid_hole";
+        // records-per-range large so the grid never splits: the only split is
+        // the hole itself.
+        let table = test_table_with_path(table_path, table_options("100"));
+        setup_dirs(&table).await;
+
+        // Real data spanning row-ids [0, 9].
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&data_batch(
+                (1..=10).collect(),
+                vec!["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"],
+            ))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let coverage = data_row_id_coverage(&table).await;
+        assert_eq!(coverage.len(), 1, "data must be one contiguous range");
+        assert_eq!(coverage[0].from(), 0);
+        let last_row = coverage[0].to();
+        assert!(last_row >= 9, "need at least 10 rows for a mid hole");
+
+        // Inject a mid-coverage indexed range [hole_start, hole_end] for the
+        // `name` field directly into the index manifest.
+        let name_field_id = find_index_field(&table, "name").unwrap().id();
+        let hole_start = 4;
+        let hole_end = 6;
+        let synthetic = IndexFileMeta {
+            index_type: BTREE_INDEX_TYPE.to_string(),
+            file_name: "btree-synthetic-hole.index".to_string(),
+            file_size: 1,
+            row_count: (hole_end - hole_start + 1) as i32,
+            deletion_vectors_ranges: None,
+            global_index_meta: Some(GlobalIndexMeta {
+                row_range_start: hole_start,
+                row_range_end: hole_end,
+                index_field_id: name_field_id,
+                extra_field_ids: None,
+                index_meta: None,
+            }),
+        };
+        let mut message = 
CommitMessage::new(BinaryRow::new(0).to_serialized_bytes(), 0, vec![]);
+        message.new_index_files = vec![synthetic];
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(vec![message])
+            .await
+            .unwrap();
+
+        let before_names = latest_btree_index_files(&table)
+            .await
+            .into_iter()
+            .map(|f| f.file_name)
+            .collect::<std::collections::BTreeSet<_>>();
+
+        // Build: gap = coverage minus the hole = [0, hole_start-1] and
+        // [hole_end+1, last_row]; two shards since the grid does not split 
here.
+        let built = table
+            .new_btree_global_index_build_builder()
+            .with_index_column("name")
+            .execute()
+            .await
+            .unwrap();
+        assert_eq!(
+            built, 2,
+            "mid-coverage hole must split the gap into two shards"
+        );
+
+        let mut new_metas = latest_btree_index_files(&table)
+            .await
+            .into_iter()
+            .filter(|f| !before_names.contains(&f.file_name))
+            .filter_map(|f| f.global_index_meta)
+            .map(|m| (m.row_range_start, m.row_range_end))
+            .collect::<Vec<_>>();
+        new_metas.sort();
+
+        assert_eq!(
+            new_metas,
+            vec![(0, hole_start - 1), (hole_end + 1, last_row)],
+            "new shards must fill the coverage on both sides of the indexed 
hole"
+        );
+        for (start, end) in &new_metas {
+            assert!(
+                *end < hole_start || *start > hole_end,
+                "new shard [{start}, {end}] must not overlap indexed hole 
[{hole_start}, {hole_end}]"
+            );
+        }
+    }
 }
diff --git a/crates/paimon/src/table/global_index_build_common.rs 
b/crates/paimon/src/table/global_index_build_common.rs
new file mode 100644
index 0000000..355a56e
--- /dev/null
+++ b/crates/paimon/src/table/global_index_build_common.rs
@@ -0,0 +1,129 @@
+// 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.
+
+//! Shared helpers for the global-index build builders (btree, lumina, vindex).
+//!
+//! Mirrors Java `GlobalIndexBuilderUtils`, which exposes a single
+//! `indexedRowRanges` used by both the sorted and vector index builders. The
+//! gap computation (what is already indexed for a given field, so a build only
+//! covers the new rows) is identical across index types apart from the
+//! `index_type` string, so it lives here once rather than being copied into
+//! each builder.
+
+use crate::spec::{FileKind, IndexManifest};
+use crate::table::{merge_row_ranges, RowRange, Table};
+use crate::{Error, Result};
+
+/// Java `sameExtraFieldIds`: null/empty are equal; otherwise exact ordered 
equality.
+pub(crate) fn same_extra_field_ids(a: Option<&[i32]>, b: Option<&[i32]>) -> 
bool {
+    let a = a.unwrap_or(&[]);
+    let b = b.unwrap_or(&[]);
+    a == b
+}
+
+/// Inclusive `[start, end]` range overlap.
+fn ranges_overlap(a_start: i64, a_end: i64, b_start: i64, b_end: i64) -> bool {
+    a_start <= b_end && b_start <= a_end
+}
+
+/// Row ranges already covered by `index_type` global-index files for
+/// `index_field_id` (and matching `extra_field_ids`). Mirrors Java
+/// `GlobalIndexBuilderUtils.indexedRowRanges`.
+pub(crate) async fn indexed_row_ranges(
+    table: &Table,
+    index_manifest_name: Option<&str>,
+    index_type: &str,
+    index_field_id: i32,
+    extra_field_ids: Option<&[i32]>,
+) -> Result<Vec<RowRange>> {
+    let Some(index_manifest_name) = index_manifest_name else {
+        return Ok(Vec::new());
+    };
+    let path = format!(
+        "{}/manifest/{}",
+        table.location().trim_end_matches('/'),
+        index_manifest_name
+    );
+    let entries = IndexManifest::read(table.file_io(), &path).await?;
+    let mut ranges = Vec::new();
+    for entry in entries {
+        if entry.kind != FileKind::Add || entry.index_file.index_type != 
index_type {
+            continue;
+        }
+        let Some(meta) = entry.index_file.global_index_meta else {
+            continue;
+        };
+        if meta.index_field_id != index_field_id
+            || !same_extra_field_ids(meta.extra_field_ids.as_deref(), 
extra_field_ids)
+        {
+            continue;
+        }
+        ranges.push(RowRange::new(meta.row_range_start, meta.row_range_end));
+    }
+    Ok(merge_row_ranges(ranges))
+}
+
+/// Guard against building a global index over a row range that an existing 
index
+/// file of the SAME identity already covers. Identity is the full
+/// `(index_type, index_field_id, extra_field_ids)` tuple -- matching
+/// `indexed_row_ranges` and Java `GlobalIndexIdentifier` -- so a different 
index
+/// type (or different `extra_field_ids`) on the same field coexists. Two files
+/// of the same identity with overlapping ranges are still rejected.
+pub(crate) async fn validate_existing_index_overlap(
+    table: &Table,
+    index_manifest_name: Option<&str>,
+    index_type: &str,
+    index_field_id: i32,
+    extra_field_ids: Option<&[i32]>,
+    planned: &[RowRange],
+) -> Result<()> {
+    let Some(index_manifest_name) = index_manifest_name else {
+        return Ok(());
+    };
+    let path = format!(
+        "{}/manifest/{}",
+        table.location().trim_end_matches('/'),
+        index_manifest_name
+    );
+    let entries = IndexManifest::read(table.file_io(), &path).await?;
+    for entry in entries {
+        if entry.kind != FileKind::Add || entry.index_file.index_type != 
index_type {
+            continue;
+        }
+        let Some(meta) = entry.index_file.global_index_meta else {
+            continue;
+        };
+        if meta.index_field_id != index_field_id
+            || !same_extra_field_ids(meta.extra_field_ids.as_deref(), 
extra_field_ids)
+        {
+            continue;
+        }
+        if planned
+            .iter()
+            .any(|r| ranges_overlap(meta.row_range_start, meta.row_range_end, 
r.from(), r.to()))
+        {
+            return Err(Error::DataInvalid {
+                message: format!(
+                    "Existing global index file '{}' overlaps requested row 
range for field {}",
+                    entry.index_file.file_name, index_field_id
+                ),
+                source: None,
+            });
+        }
+    }
+    Ok(())
+}
diff --git a/crates/paimon/src/table/global_index_scanner.rs 
b/crates/paimon/src/table/global_index_scanner.rs
index 07b4858..57cce1c 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -850,43 +850,6 @@ fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) 
-> Vec<RowRange> {
     result
 }
 
-fn exclude_row_ranges(data_ranges: &[RowRange], indexed_ranges: &[RowRange]) 
-> Vec<RowRange> {
-    let data_ranges = super::merge_row_ranges(data_ranges.to_vec());
-    if data_ranges.is_empty() {
-        return Vec::new();
-    }
-    let indexed_ranges = super::merge_row_ranges(indexed_ranges.to_vec());
-    if indexed_ranges.is_empty() {
-        return data_ranges;
-    }
-
-    let mut result = Vec::new();
-    for data_range in data_ranges {
-        let mut cursor = data_range.from();
-        let mut exhausted = false;
-        for indexed_range in &indexed_ranges {
-            if indexed_range.to() < cursor {
-                continue;
-            }
-            if indexed_range.from() > data_range.to() {
-                break;
-            }
-            if indexed_range.from() > cursor {
-                result.push(RowRange::new(cursor, indexed_range.from() - 1));
-            }
-            if indexed_range.to() >= data_range.to() {
-                exhausted = true;
-                break;
-            }
-            cursor = cursor.max(indexed_range.to() + 1);
-        }
-        if !exhausted && cursor <= data_range.to() {
-            result.push(RowRange::new(cursor, data_range.to()));
-        }
-    }
-    super::merge_row_ranges(result)
-}
-
 fn data_ranges_for_search_mode(
     search_mode: GlobalIndexSearchMode,
     next_row_id: Option<i64>,
@@ -941,7 +904,7 @@ fn unindexed_ranges_for_coverage(
         return Vec::new();
     };
     let indexed_ranges = indexed_ranges_from_coverage(coverage_by_field, 
field_ids);
-    exclude_row_ranges(&data_ranges, &indexed_ranges)
+    super::source::exclude_row_ranges(&data_ranges, &indexed_ranges)
 }
 
 /// Compute row ranges not covered by a family of global index files.
diff --git a/crates/paimon/src/table/lumina_index_build_builder.rs 
b/crates/paimon/src/table/lumina_index_build_builder.rs
index b6c06c1..5ead6de 100644
--- a/crates/paimon/src/table/lumina_index_build_builder.rs
+++ b/crates/paimon/src/table/lumina_index_build_builder.rs
@@ -21,8 +21,9 @@ use crate::lumina::{
 };
 use crate::spec::{
     bucket_dir_name, BinaryRow, CoreOptions, DataField, DataFileMeta, 
DataType, FileKind,
-    GlobalIndexMeta, IndexFileMeta, IndexManifest, ROW_ID_FIELD_NAME,
+    GlobalIndexMeta, IndexFileMeta, ROW_ID_FIELD_NAME,
 };
+use crate::table::source::exclude_row_ranges;
 use crate::table::{
     CommitMessage, DataSplitBuilder, RowRange, SnapshotManager, Table, 
TableCommit,
 };
@@ -118,6 +119,14 @@ impl<'a> LuminaIndexBuildBuilder<'a> {
             .with_scan_all_files()
             .plan_manifest_entries(&snapshot)
             .await?;
+        let indexed = 
crate::table::global_index_build_common::indexed_row_ranges(
+            self.table,
+            snapshot.index_manifest(),
+            LUMINA_IDENTIFIER,
+            index_field.id(),
+            None, // single-column build; no extra fields today
+        )
+        .await?;
         let shards = plan_lumina_shards(
             self.table.location(),
             self.table.schema().partition_keys(),
@@ -126,16 +135,22 @@ impl<'a> LuminaIndexBuildBuilder<'a> {
             snapshot.id(),
             manifest_entries,
             rows_per_shard,
+            &indexed,
         )?;
         if shards.is_empty() {
             return Ok(0);
         }
 
-        validate_existing_index_overlap(
+        
crate::table::global_index_build_common::validate_existing_index_overlap(
             self.table,
             snapshot.index_manifest(),
+            LUMINA_IDENTIFIER,
             index_field.id(),
-            &shards,
+            None,
+            &shards
+                .iter()
+                .map(|shard| RowRange::new(shard.row_range_start, 
shard.row_range_end))
+                .collect::<Vec<_>>(),
         )
         .await?;
 
@@ -356,6 +371,7 @@ fn resolve_lumina_options(
     Ok(options)
 }
 
+#[allow(clippy::too_many_arguments)]
 fn plan_lumina_shards(
     table_location: &str,
     partition_keys: &[String],
@@ -364,6 +380,7 @@ fn plan_lumina_shards(
     snapshot_id: i64,
     entries: Vec<crate::spec::ManifestEntry>,
     rows_per_shard: i64,
+    indexed: &[RowRange],
 ) -> Result<Vec<LuminaIndexShard>> {
     if rows_per_shard <= 0 {
         return Err(Error::DataInvalid {
@@ -446,19 +463,29 @@ fn plan_lumina_shards(
                     .map(|file| file.row_id_range().unwrap().1)
                     .max()
                     .unwrap();
-                let row_range_start = group_start.max(shard_start);
-                let row_range_end = group_end.min(shard_end);
-                result.push(LuminaIndexShard {
-                    partition: partition.clone(),
-                    partition_bytes: partition_bytes.clone(),
-                    files: group,
-                    row_range_start,
-                    row_range_end,
-                    snapshot_id,
-                    source_bucket,
-                    total_buckets,
-                    bucket_path: bucket_path.clone(),
-                });
+                // Coverage of this group clamped to the current shard cell. 
Then
+                // subtract the already-indexed ranges so the build only covers
+                // the gap. Because grid-clamp and gap-subtraction are both 
range
+                // intersections, applying the gap here is equivalent to 
btree's
+                // "exclude then split" -- and each surviving segment stays 
inside
+                // one shard cell, preserving per-shard row-id contiguity.
+                let coverage_start = group_start.max(shard_start);
+                let coverage_end = group_end.min(shard_end);
+                let build_segments =
+                    exclude_row_ranges(&[RowRange::new(coverage_start, 
coverage_end)], indexed);
+                for seg in build_segments {
+                    result.push(LuminaIndexShard {
+                        partition: partition.clone(),
+                        partition_bytes: partition_bytes.clone(),
+                        files: group.clone(),
+                        row_range_start: seg.from(),
+                        row_range_end: seg.to(),
+                        snapshot_id,
+                        source_bucket,
+                        total_buckets,
+                        bucket_path: bucket_path.clone(),
+                    });
+                }
             }
         }
     }
@@ -535,51 +562,6 @@ fn bucket_path(
     ))
 }
 
-async fn validate_existing_index_overlap(
-    table: &Table,
-    index_manifest_name: Option<&str>,
-    index_field_id: i32,
-    shards: &[LuminaIndexShard],
-) -> Result<()> {
-    let Some(index_manifest_name) = index_manifest_name else {
-        return Ok(());
-    };
-    let path = format!(
-        "{}/manifest/{}",
-        table.location().trim_end_matches('/'),
-        index_manifest_name
-    );
-    let entries = IndexManifest::read(table.file_io(), &path).await?;
-    for entry in entries {
-        if entry.kind != FileKind::Add {
-            continue;
-        }
-        let Some(meta) = entry.index_file.global_index_meta else {
-            continue;
-        };
-        if meta.index_field_id != index_field_id {
-            continue;
-        }
-        if shards.iter().any(|shard| {
-            ranges_overlap(
-                meta.row_range_start,
-                meta.row_range_end,
-                shard.row_range_start,
-                shard.row_range_end,
-            )
-        }) {
-            return Err(Error::DataInvalid {
-                message: format!(
-                    "Existing global index file '{}' overlaps requested row 
range for field {}",
-                    entry.index_file.file_name, index_field_id
-                ),
-                source: None,
-            });
-        }
-    }
-    Ok(())
-}
-
 async fn extract_vectors(
     table: &Table,
     shard: &LuminaIndexShard,
@@ -873,10 +855,6 @@ async fn copy_local_file_to_output(
     writer.close().await
 }
 
-fn ranges_overlap(left_start: i64, left_end: i64, right_start: i64, right_end: 
i64) -> bool {
-    left_start <= right_end && right_start <= left_end
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -886,7 +864,8 @@ mod tests {
     use crate::lumina::LUMINA_DIMENSION_OPTION;
     use crate::spec::stats::BinaryTableStats;
     use crate::spec::{
-        ArrayType, DoubleType, FloatType, IntType, ManifestEntry, Schema, 
TableSchema, VectorType,
+        ArrayType, DoubleType, FloatType, IndexManifest, IntType, 
ManifestEntry, Schema,
+        TableSchema, VectorType,
     };
     use crate::table::TableWrite;
     use arrow_array::builder::{FixedSizeListBuilder, Float32Builder, 
Int64Builder, ListBuilder};
@@ -993,6 +972,14 @@ mod tests {
     }
 
     fn plan(entries: Vec<ManifestEntry>, rows_per_shard: i64) -> 
Result<Vec<LuminaIndexShard>> {
+        plan_with_indexed(entries, rows_per_shard, &[])
+    }
+
+    fn plan_with_indexed(
+        entries: Vec<ManifestEntry>,
+        rows_per_shard: i64,
+        indexed: &[RowRange],
+    ) -> Result<Vec<LuminaIndexShard>> {
         let table = test_table(table_options(&rows_per_shard.to_string()));
         let core = CoreOptions::new(table.schema().options());
         plan_lumina_shards(
@@ -1003,6 +990,7 @@ mod tests {
             1,
             entries,
             rows_per_shard,
+            indexed,
         )
     }
 
@@ -1660,4 +1648,399 @@ mod tests {
         let status = file_io.get_status(&index_path).await.unwrap();
         assert_eq!(index_file.file_size as u64, status.size);
     }
+
+    fn lumina_e2e_options(rows_per_shard: &str) -> HashMap<String, String> {
+        let mut options = table_options(rows_per_shard);
+        options.insert("lumina.index.dimension".to_string(), "2".to_string());
+        options.insert("lumina.encoding.type".to_string(), 
"rawf32".to_string());
+        options
+    }
+
+    fn lumina_e2e_table(table_path: &str, rows_per_shard: &str) -> Table {
+        test_table_with_io(
+            FileIOBuilder::new("memory").build().unwrap(),
+            table_path,
+            vector_schema_builder(lumina_e2e_options(rows_per_shard))
+                .build()
+                .unwrap(),
+        )
+    }
+
+    async fn write_vectors(table: &Table, ids: Vec<i32>, vectors: 
Vec<Vec<f32>>) {
+        let mut table_write = TableWrite::new(table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&build_vector_batch(ids, vectors))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+    }
+
+    /// Commit a synthetic Lumina `IndexFileMeta` covering `[start, end]` for
+    /// `field_id` directly into the index manifest, without invoking the 
native
+    /// builder. Mirrors the btree mid-hole test so the incremental gap logic 
can
+    /// be exercised in CI where the native Lumina library is unavailable.
+    async fn commit_synthetic_lumina_index(table: &Table, field_id: i32, 
start: i64, end: i64) {
+        let synthetic = IndexFileMeta {
+            index_type: LUMINA_IDENTIFIER.to_string(),
+            file_name: format!("lumina-synthetic-{start}-{end}.index"),
+            file_size: 1,
+            row_count: (end - start + 1) as i32,
+            deletion_vectors_ranges: None,
+            global_index_meta: Some(GlobalIndexMeta {
+                row_range_start: start,
+                row_range_end: end,
+                index_field_id: field_id,
+                extra_field_ids: None,
+                index_meta: None,
+            }),
+        };
+        let mut message = 
CommitMessage::new(BinaryRow::new(0).to_serialized_bytes(), 0, vec![]);
+        message.new_index_files = vec![synthetic];
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(vec![message])
+            .await
+            .unwrap();
+    }
+
+    async fn latest_lumina_index_files(table: &Table) -> Vec<IndexFileMeta> {
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let Some(index_manifest_name) = snapshot.index_manifest() else {
+            return Vec::new();
+        };
+        IndexManifest::read(
+            table.file_io(),
+            &snapshot_manager.manifest_path(index_manifest_name),
+        )
+        .await
+        .unwrap()
+        .into_iter()
+        .filter(|entry| {
+            entry.kind == FileKind::Add && entry.index_file.index_type == 
LUMINA_IDENTIFIER
+        })
+        .map(|entry| entry.index_file)
+        .collect()
+    }
+
+    /// Row-id coverage of the committed data files, read back from the data
+    /// manifest (never hard-coded) and merged into contiguous ranges. Mirrors
+    /// how `execute` gathers `manifest_entries`.
+    async fn data_row_id_coverage(table: &Table) -> Vec<RowRange> {
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let entries = table
+            .new_read_builder()
+            .new_scan()
+            .with_scan_all_files()
+            .plan_manifest_entries(&snapshot)
+            .await
+            .unwrap();
+        let ranges = entries
+            .iter()
+            .filter(|entry| *entry.kind() == FileKind::Add)
+            .filter_map(|entry| {
+                entry
+                    .file()
+                    .row_id_range()
+                    .map(|(start, end)| RowRange::new(start, end))
+            })
+            .collect::<Vec<_>>();
+        crate::table::merge_row_ranges(ranges)
+    }
+
+    /// Second build with the whole coverage already indexed must be a clean
+    /// no-op (returns 0), not an overlap error. Reaches `Ok(0)` before the
+    /// native build, so it runs in CI without the Lumina library. This is the
+    /// core bug fix: today the second call errors with the overlap message.
+    #[tokio::test]
+    async fn lumina_second_build_without_new_data_is_noop() {
+        let table_path = "memory:/test_lumina_second_build_noop";
+        let table = lumina_e2e_table(table_path, "10");
+        setup_dirs(table.file_io(), table_path).await;
+
+        write_vectors(
+            &table,
+            vec![1, 2, 3],
+            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]],
+        )
+        .await;
+
+        // Fully index the coverage via a synthetic manifest entry.
+        let coverage = data_row_id_coverage(&table).await;
+        assert_eq!(coverage.len(), 1, "data must be one contiguous range");
+        let field_id = find_index_field(&table, "embedding").unwrap().id();
+        commit_synthetic_lumina_index(&table, field_id, coverage[0].from(), 
coverage[0].to()).await;
+
+        let names_before = latest_lumina_index_files(&table)
+            .await
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        assert!(!names_before.is_empty());
+
+        let built = table
+            .new_lumina_index_build_builder()
+            .with_index_column("embedding")
+            .execute()
+            .await
+            .unwrap();
+        assert_eq!(built, 0, "fully-indexed table must build nothing on 
re-run");
+
+        let names_after = latest_lumina_index_files(&table)
+            .await
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        assert_eq!(
+            names_before, names_after,
+            "re-run must not add or remove index manifest entries"
+        );
+    }
+
+    /// Build over an already-indexed prefix, then append new rows: the second
+    /// build must target only the appended gap and must NOT fail with the
+    /// overlap error. Without the native Lumina library the gap build 
surfaces a
+    /// library-load error (not the overlap error); with it present it 
succeeds.
+    #[tokio::test]
+    async fn lumina_incremental_build_indexes_only_new_rows() {
+        let table_path = "memory:/test_lumina_incremental";
+        let table = lumina_e2e_table(table_path, "10");
+        setup_dirs(table.file_io(), table_path).await;
+
+        // Initial batch, then mark it fully indexed via a synthetic entry.
+        write_vectors(
+            &table,
+            vec![1, 2, 3],
+            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]],
+        )
+        .await;
+        let indexed_coverage = data_row_id_coverage(&table).await;
+        assert_eq!(indexed_coverage.len(), 1);
+        let n = indexed_coverage[0].to() + 1;
+        let field_id = find_index_field(&table, "embedding").unwrap().id();
+        commit_synthetic_lumina_index(
+            &table,
+            field_id,
+            indexed_coverage[0].from(),
+            indexed_coverage[0].to(),
+        )
+        .await;
+
+        // Append a second batch (new row-ids [n..]).
+        write_vectors(
+            &table,
+            vec![4, 5, 6],
+            vec![vec![2.0, 0.0], vec![0.0, 2.0], vec![2.0, 2.0]],
+        )
+        .await;
+
+        // White-box: fed the real indexed ranges from the manifest, the 
planner
+        // must target only the appended gap [n, ..], never the already-indexed
+        // prefix. Computed before `execute` so it is independent of whether 
the
+        // native build (which needs the Lumina library) runs.
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let manifest_entries = table
+            .new_read_builder()
+            .new_scan()
+            .with_scan_all_files()
+            .plan_manifest_entries(&snapshot)
+            .await
+            .unwrap();
+        let indexed = 
crate::table::global_index_build_common::indexed_row_ranges(
+            &table,
+            snapshot.index_manifest(),
+            LUMINA_IDENTIFIER,
+            field_id,
+            None,
+        )
+        .await
+        .unwrap();
+        let core = CoreOptions::new(table.schema().options());
+        let shards = plan_lumina_shards(
+            table.location(),
+            table.schema().partition_keys(),
+            table.schema().fields(),
+            &core,
+            snapshot.id(),
+            manifest_entries,
+            10,
+            &indexed,
+        )
+        .unwrap();
+        assert!(!shards.is_empty(), "appended gap must produce build shards");
+        for shard in &shards {
+            assert!(
+                shard.row_range_start >= n,
+                "shard [{}, {}] must start at or after the indexed prefix end 
{n}",
+                shard.row_range_start,
+                shard.row_range_end
+            );
+        }
+
+        // End-to-end: the incremental build must no longer fail with the 
overlap
+        // error. Without the native Lumina library the gap build surfaces a
+        // library-load error instead; with it present it succeeds.
+        let result = table
+            .new_lumina_index_build_builder()
+            .with_index_column("embedding")
+            .execute()
+            .await;
+        match result {
+            Ok(_) => {}
+            Err(Error::DataInvalid { message, .. }) => {
+                assert!(
+                    !message.contains("overlaps requested row range"),
+                    "incremental build must not fail with the overlap error; 
got: {message}"
+                );
+            }
+            Err(other) => panic!("unexpected error from incremental build: 
{other:?}"),
+        }
+    }
+
+    /// Regression: a first build (no existing index) must equal the pre-change
+    /// full build -- subtracting an empty `indexed` yields full coverage.
+    #[test]
+    fn lumina_first_build_indexes_full_coverage() {
+        let full = plan(vec![manifest_entry(data_file("a", Some(0), 25))], 
10).unwrap();
+        let gapped =
+            plan_with_indexed(vec![manifest_entry(data_file("a", Some(0), 
25))], 10, &[]).unwrap();
+        // Empty `indexed` must not alter the shard layout.
+        assert_eq!(
+            full.iter()
+                .map(|s| (s.row_range_start, s.row_range_end))
+                .collect::<Vec<_>>(),
+            gapped
+                .iter()
+                .map(|s| (s.row_range_start, s.row_range_end))
+                .collect::<Vec<_>>()
+        );
+        assert_eq!(
+            full.iter()
+                .map(|s| (s.row_range_start, s.row_range_end))
+                .collect::<Vec<_>>(),
+            vec![(0, 9), (10, 19), (20, 24)],
+            "first build must cover the full row range across shards"
+        );
+    }
+
+    /// Planner-level mid-coverage hole, mirroring btree's
+    /// `incremental_build_splits_gap_around_mid_coverage_indexed_hole`: with a
+    /// single shard cell (rows_per_shard large enough to hold all data) the 
grid
+    /// never splits, so the only split is the indexed hole itself. An indexed
+    /// range strictly inside the data coverage must carve the build into 
exactly
+    /// the two contiguous segments on either side of the hole -- both bounds
+    /// pinned, and neither segment may span or touch the hole.
+    #[test]
+    fn lumina_plan_splits_gap_around_mid_coverage_indexed_hole() {
+        // Data row-ids [0, 9]; one shard cell [0, 99] so the grid never 
splits.
+        let n = 9;
+        let hole_start = 4;
+        let hole_end = 6;
+        let shards = plan_with_indexed(
+            vec![manifest_entry(data_file("a", Some(0), n + 1))],
+            100,
+            &[RowRange::new(hole_start, hole_end)],
+        )
+        .unwrap();
+
+        let ranges = shards
+            .iter()
+            .map(|s| (s.row_range_start, s.row_range_end))
+            .collect::<Vec<_>>();
+        // Exactly the two contiguous segments around the hole.
+        assert_eq!(
+            ranges,
+            vec![(0, hole_start - 1), (hole_end + 1, n)],
+            "mid-coverage hole must split into exactly the two segments around 
it"
+        );
+        // Every emitted range is contiguous and none spans or touches the 
hole.
+        for (start, end) in &ranges {
+            assert!(end >= start, "range must be non-empty: [{start}, {end}]");
+            assert!(
+                *end < hole_start || *start > hole_end,
+                "shard [{start}, {end}] must not overlap indexed hole 
[{hole_start}, {hole_end}]"
+            );
+        }
+        // Together the shards cover exactly coverage - indexed.
+        let expected = exclude_row_ranges(
+            &[RowRange::new(0, n)],
+            &[RowRange::new(hole_start, hole_end)],
+        )
+        .into_iter()
+        .map(|r| (r.from(), r.to()))
+        .collect::<Vec<_>>();
+        assert_eq!(
+            ranges, expected,
+            "shards must cover exactly coverage minus the indexed hole"
+        );
+    }
+
+    /// Planner-level incremental prefix. Strengthens
+    /// `lumina_incremental_build_indexes_only_new_rows`, which asserted only a
+    /// one-sided lower bound (`row_range_start >= n`): an indexed prefix [0, 
k]
+    /// must leave EXACTLY the suffix [k+1, N] on both bounds, split along the
+    /// shard grid, with nothing re-indexed inside the prefix.
+    #[test]
+    fn lumina_plan_incremental_prefix_leaves_suffix() {
+        // Data row-ids [0, 24], rows_per_shard = 10 -> cells 
[0,9],[10,19],[20,29].
+        // Indexed prefix [0, 9] fully fills the first cell, so the build must 
be
+        // exactly [10, 19] and [20, 24] (the suffix split along the grid).
+        let n = 24;
+        let k = 9; // prefix [0, k] == the first full shard cell
+        let shards = plan_with_indexed(
+            vec![manifest_entry(data_file("a", Some(0), n + 1))],
+            10,
+            &[RowRange::new(0, k)],
+        )
+        .unwrap();
+
+        let ranges = shards
+            .iter()
+            .map(|s| (s.row_range_start, s.row_range_end))
+            .collect::<Vec<_>>();
+        assert_eq!(
+            ranges,
+            vec![(k + 1, 19), (20, n)],
+            "indexed prefix must leave exactly the suffix, split along the 
shard grid"
+        );
+        // Both bounds pinned (this is what the one-sided existing check 
omits).
+        assert_eq!(ranges.first().unwrap().0, k + 1, "suffix must start at 
k+1");
+        assert_eq!(ranges.last().unwrap().1, n, "suffix must end at N");
+        // Contiguous, and no shard reaches back into the indexed prefix.
+        for pair in ranges.windows(2) {
+            assert_eq!(
+                pair[1].0,
+                pair[0].1 + 1,
+                "ranges must be contiguous: {:?} then {:?}",
+                pair[0],
+                pair[1]
+            );
+        }
+        for (start, end) in &ranges {
+            assert!(
+                *start > k,
+                "shard [{start}, {end}] must not re-index the prefix [0, {k}]"
+            );
+        }
+    }
 }
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 373efa5..70eef2d 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -39,6 +39,7 @@ mod data_file_reader;
 mod data_file_writer;
 #[cfg(feature = "fulltext")]
 mod full_text_search_builder;
+pub(crate) mod global_index_build_common;
 pub(crate) mod global_index_scanner;
 mod global_index_types;
 mod hybrid_search_builder;
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 7c85d6d..88ccfd0 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -130,6 +130,44 @@ pub fn merge_row_ranges(mut ranges: Vec<RowRange>) -> 
Vec<RowRange> {
     merged
 }
 
+/// Subtract `subtract` from `base`, returning the remaining sorted, 
non-overlapping
+/// inclusive ranges. Mirrors Java `Range.exclude`. Inputs need not be sorted 
or
+/// merged; the result is normalized (sorted, merged-adjacent, non-empty 
ranges).
+pub(crate) fn exclude_row_ranges(base: &[RowRange], subtract: &[RowRange]) -> 
Vec<RowRange> {
+    let base = merge_row_ranges(base.to_vec());
+    let subtract = merge_row_ranges(subtract.to_vec());
+    let mut result = Vec::new();
+    for seg in base {
+        // Current uncovered cursor within [seg.from, seg.to].
+        let mut cursor = seg.from();
+        // Set once a cut covers through seg.to(); the trailing suffix must 
then
+        // be suppressed. Distinguishing this from `cursor > seg.to()` is what
+        // keeps the i64::MAX edge correct: at i64::MAX, `saturating_add(1)`
+        // cannot push the cursor past seg.to(), so we rely on this flag 
instead.
+        let mut exhausted = false;
+        for cut in &subtract {
+            if cut.to() < cursor || cut.from() > seg.to() {
+                continue; // cut entirely before the cursor or beyond this 
segment
+            }
+            if cut.from() > cursor {
+                // Emit the gap before this cut.
+                result.push(RowRange::new(cursor, cut.from() - 1));
+            }
+            if cut.to() >= seg.to() {
+                // The cut reaches or passes the segment end; nothing remains.
+                exhausted = true;
+                break;
+            }
+            // Advance the cursor past the cut (never move it backwards).
+            cursor = cursor.max(cut.to() + 1);
+        }
+        if !exhausted && cursor <= seg.to() {
+            result.push(RowRange::new(cursor, seg.to()));
+        }
+    }
+    result
+}
+
 #[cfg(test)]
 mod row_range_tests {
     use super::*;
@@ -905,4 +943,105 @@ mod tests {
             .unwrap();
         assert_eq!(unknown.merged_row_count(), None);
     }
+
+    fn r(from: i64, to: i64) -> RowRange {
+        RowRange::new(from, to)
+    }
+
+    fn pairs(ranges: &[RowRange]) -> Vec<(i64, i64)> {
+        ranges.iter().map(|x| (x.from(), x.to())).collect()
+    }
+
+    #[test]
+    fn exclude_empty_subtract_returns_base_merged() {
+        // No subtraction -> base, normalized (sorted, merged-adjacent).
+        let base = vec![r(0, 4), r(5, 9)];
+        assert_eq!(pairs(&exclude_row_ranges(&base, &[])), vec![(0, 9)]);
+    }
+
+    #[test]
+    fn exclude_empty_base_returns_empty() {
+        assert!(exclude_row_ranges(&[], &[r(0, 9)]).is_empty());
+    }
+
+    #[test]
+    fn exclude_no_overlap_returns_base() {
+        let base = vec![r(10, 19)];
+        let sub = vec![r(0, 9), r(20, 29)];
+        assert_eq!(pairs(&exclude_row_ranges(&base, &sub)), vec![(10, 19)]);
+    }
+
+    #[test]
+    fn exclude_prefix_leaves_suffix() {
+        // The core incremental case: indexed [0,2], data [0,9] -> build [3,9].
+        let base = vec![r(0, 9)];
+        let sub = vec![r(0, 2)];
+        assert_eq!(pairs(&exclude_row_ranges(&base, &sub)), vec![(3, 9)]);
+    }
+
+    #[test]
+    fn exclude_middle_hole_splits_range() {
+        let base = vec![r(0, 9)];
+        let sub = vec![r(3, 5)];
+        assert_eq!(
+            pairs(&exclude_row_ranges(&base, &sub)),
+            vec![(0, 2), (6, 9)]
+        );
+    }
+
+    #[test]
+    fn exclude_full_cover_returns_empty() {
+        let base = vec![r(0, 9)];
+        let sub = vec![r(0, 9)];
+        assert!(exclude_row_ranges(&base, &sub).is_empty());
+    }
+
+    #[test]
+    fn exclude_superset_cover_returns_empty() {
+        let base = vec![r(2, 7)];
+        let sub = vec![r(0, 100)];
+        assert!(exclude_row_ranges(&base, &sub).is_empty());
+    }
+
+    #[test]
+    fn exclude_multiple_subtract_ranges_and_boundaries() {
+        // Adjacent subtract ranges must not leave a spurious 1-row gap.
+        let base = vec![r(0, 20)];
+        let sub = vec![r(0, 4), r(5, 9), r(15, 15)];
+        assert_eq!(
+            pairs(&exclude_row_ranges(&base, &sub)),
+            vec![(10, 14), (16, 20)]
+        );
+    }
+
+    #[test]
+    fn exclude_multi_base_segments() {
+        let base = vec![r(0, 4), r(10, 14)];
+        let sub = vec![r(2, 11)];
+        assert_eq!(
+            pairs(&exclude_row_ranges(&base, &sub)),
+            vec![(0, 1), (12, 14)]
+        );
+    }
+
+    #[test]
+    fn exclude_saturates_at_i64_max_boundary() {
+        // A cut covering through i64::MAX must exhaust the segment, not emit a
+        // spurious (i64::MAX, i64::MAX). Guards the shared primitive against 
the
+        // i64::MAX saturation edge (read + write paths both rely on this fn).
+        let base = vec![RowRange::new(5, i64::MAX)];
+        let sub = vec![RowRange::new(5, i64::MAX)];
+        assert!(exclude_row_ranges(&base, &sub).is_empty());
+
+        // Partial cut up to i64::MAX from the middle leaves the prefix only.
+        let base = vec![RowRange::new(0, i64::MAX)];
+        let sub = vec![RowRange::new(10, i64::MAX)];
+        assert_eq!(
+            exclude_row_ranges(&base, &sub)
+                .iter()
+                .map(|r| (r.from(), r.to()))
+                .collect::<Vec<_>>(),
+            vec![(0, 9)]
+        );
+    }
 }
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index 872bea1..453d8aa 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -30,6 +30,7 @@ use crate::spec::{
     PartitionStatistics, Predicate, Snapshot, EMPTY_SERIALIZED_ROW, 
MANIFEST_ENTRY_SCHEMA,
 };
 use crate::table::commit_message::CommitMessage;
+use crate::table::global_index_build_common::same_extra_field_ids;
 use crate::table::partition_filter::PartitionFilter;
 use crate::table::snapshot_commit::SnapshotCommit;
 use crate::table::{SnapshotManager, Table, TableScan};
@@ -1384,6 +1385,11 @@ impl TableCommit {
                     continue;
                 };
                 if retained_meta.index_field_id == added_meta.index_field_id
+                    && retained.index_file.index_type == 
added.index_file.index_type
+                    && same_extra_field_ids(
+                        retained_meta.extra_field_ids.as_deref(),
+                        added_meta.extra_field_ids.as_deref(),
+                    )
                     && ranges_overlap(
                         retained_meta.row_range_start,
                         retained_meta.row_range_end,
@@ -1419,6 +1425,11 @@ impl TableCommit {
                     continue;
                 };
                 if left_meta.index_field_id == right_meta.index_field_id
+                    && left.index_file.index_type == 
right.index_file.index_type
+                    && same_extra_field_ids(
+                        left_meta.extra_field_ids.as_deref(),
+                        right_meta.extra_field_ids.as_deref(),
+                    )
                     && ranges_overlap(
                         left_meta.row_range_start,
                         left_meta.row_range_end,
@@ -3322,6 +3333,132 @@ mod tests {
         assert_eq!(index_entries.len(), 2);
     }
 
+    fn add_index_entry(file: IndexFileMeta) -> IndexManifestEntry {
+        IndexManifestEntry {
+            kind: FileKind::Add,
+            partition: vec![],
+            bucket: 0,
+            index_file: file,
+            version: 1,
+        }
+    }
+
+    #[test]
+    fn test_global_index_overlap_allows_different_index_type() {
+        // Same field id (0) and overlapping row ranges, but the retained 
entry is
+        // a `lumina` index and the added one is `btree`: distinct identities 
that
+        // must coexist rather than trip the overlap guard.
+        let lumina = test_global_index_file("lumina-0.index", 0, 0, 9);
+        let mut btree = test_global_index_file("btree-0.index", 0, 5, 14);
+        btree.index_type = "btree".to_string();
+        let retained = vec![add_index_entry(lumina)];
+        let added = vec![add_index_entry(btree)];
+        TableCommit::validate_global_index_overlap(&retained, &added)
+            .expect("different index types on the same field must coexist");
+    }
+
+    #[test]
+    fn test_global_index_overlap_allows_different_extra_field_ids() {
+        // Same index type and field id, overlapping ranges, but different
+        // `extra_field_ids` -> distinct identities, must coexist.
+        let retained = 
vec![add_index_entry(test_global_index_file_with_extra_fields(
+            "lumina-0.index",
+            0,
+            vec![1],
+            0,
+            9,
+        ))];
+        let added = 
vec![add_index_entry(test_global_index_file_with_extra_fields(
+            "lumina-1.index",
+            0,
+            vec![2],
+            5,
+            14,
+        ))];
+        TableCommit::validate_global_index_overlap(&retained, &added)
+            .expect("same field but different extra_field_ids must coexist");
+    }
+
+    #[test]
+    fn test_global_index_overlap_rejects_same_identity() {
+        // Identical identity (same type + field + extra) with overlapping 
ranges
+        // must still be rejected.
+        let retained = vec![add_index_entry(test_global_index_file(
+            "lumina-0.index",
+            0,
+            0,
+            9,
+        ))];
+        let added = vec![add_index_entry(test_global_index_file(
+            "lumina-1.index",
+            0,
+            5,
+            14,
+        ))];
+        let result = TableCommit::validate_global_index_overlap(&retained, 
&added);
+        let err_msg = result.expect_err("same-identity overlap must be 
rejected");
+        let err_msg = err_msg.to_string();
+        assert!(
+            err_msg.contains("overlapping row range"),
+            "expected overlap error, got: {err_msg}"
+        );
+    }
+
+    #[test]
+    fn test_added_global_index_overlap_allows_different_index_type() {
+        // Two entries added within the same commit: same field id (0) and
+        // overlapping row ranges, but distinct index types (`lumina` vs 
`btree`).
+        // These are distinct global-index identities that must coexist.
+        let lumina = test_global_index_file("lumina-0.index", 0, 0, 9);
+        let mut btree = test_global_index_file("btree-0.index", 0, 5, 14);
+        btree.index_type = "btree".to_string();
+        let added = vec![add_index_entry(lumina), add_index_entry(btree)];
+        TableCommit::validate_added_global_index_overlap(&added)
+            .expect("different index types on the same field must coexist 
within a commit");
+    }
+
+    #[test]
+    fn test_added_global_index_overlap_allows_different_extra_field_ids() {
+        // Two entries added within the same commit: same index type and field 
id,
+        // overlapping ranges, but different `extra_field_ids` -> distinct
+        // identities, must coexist.
+        let added = vec![
+            add_index_entry(test_global_index_file_with_extra_fields(
+                "lumina-0.index",
+                0,
+                vec![1],
+                0,
+                9,
+            )),
+            add_index_entry(test_global_index_file_with_extra_fields(
+                "lumina-1.index",
+                0,
+                vec![2],
+                5,
+                14,
+            )),
+        ];
+        TableCommit::validate_added_global_index_overlap(&added)
+            .expect("same field but different extra_field_ids must coexist 
within a commit");
+    }
+
+    #[test]
+    fn test_added_global_index_overlap_rejects_same_identity() {
+        // Two entries added within the same commit with identical identity
+        // (same type + field + extra) and overlapping ranges must be rejected.
+        let added = vec![
+            add_index_entry(test_global_index_file("lumina-0.index", 0, 0, 9)),
+            add_index_entry(test_global_index_file("lumina-1.index", 0, 5, 
14)),
+        ];
+        let result = TableCommit::validate_added_global_index_overlap(&added);
+        let err_msg = result.expect_err("same-identity overlap must be 
rejected");
+        let err_msg = err_msg.to_string();
+        assert!(
+            err_msg.contains("overlapping row range"),
+            "expected overlap error, got: {err_msg}"
+        );
+    }
+
     #[tokio::test]
     async fn test_index_delete_removes_previous_index_manifest_entry() {
         let file_io = test_file_io();
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs 
b/crates/paimon/src/table/vindex_index_build_builder.rs
index 7985411..7adb48a 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -17,8 +17,9 @@
 
 use crate::spec::{
     bucket_dir_name, BinaryRow, CoreOptions, DataField, DataFileMeta, 
DataType, FileKind,
-    GlobalIndexMeta, IndexFileMeta, IndexManifest, ROW_ID_FIELD_NAME,
+    GlobalIndexMeta, IndexFileMeta, ROW_ID_FIELD_NAME,
 };
+use crate::table::source::exclude_row_ranges;
 use crate::table::{
     CommitMessage, DataSplitBuilder, RowRange, SnapshotManager, Table, 
TableCommit,
 };
@@ -117,6 +118,14 @@ impl<'a> VindexIndexBuildBuilder<'a> {
             .with_scan_all_files()
             .plan_manifest_entries(&snapshot)
             .await?;
+        let indexed = 
crate::table::global_index_build_common::indexed_row_ranges(
+            self.table,
+            snapshot.index_manifest(),
+            &self.index_type,
+            index_field.id(),
+            None, // single-column build; no extra fields today
+        )
+        .await?;
         let shards = plan_vindex_shards(
             self.table.location(),
             self.table.schema().partition_keys(),
@@ -125,16 +134,22 @@ impl<'a> VindexIndexBuildBuilder<'a> {
             snapshot.id(),
             manifest_entries,
             rows_per_shard,
+            &indexed,
         )?;
         if shards.is_empty() {
             return Ok(0);
         }
 
-        validate_existing_index_overlap(
+        
crate::table::global_index_build_common::validate_existing_index_overlap(
             self.table,
             snapshot.index_manifest(),
+            &self.index_type,
             index_field.id(),
-            &shards,
+            None,
+            &shards
+                .iter()
+                .map(|shard| RowRange::new(shard.row_range_start, 
shard.row_range_end))
+                .collect::<Vec<_>>(),
         )
         .await?;
 
@@ -336,6 +351,7 @@ fn validate_vector_field(field: &DataField) -> Result<()> {
     Ok(())
 }
 
+#[allow(clippy::too_many_arguments)]
 fn plan_vindex_shards(
     table_location: &str,
     partition_keys: &[String],
@@ -344,6 +360,7 @@ fn plan_vindex_shards(
     snapshot_id: i64,
     entries: Vec<crate::spec::ManifestEntry>,
     rows_per_shard: i64,
+    indexed: &[RowRange],
 ) -> Result<Vec<VindexIndexShard>> {
     if rows_per_shard <= 0 {
         return Err(Error::DataInvalid {
@@ -426,19 +443,30 @@ fn plan_vindex_shards(
                     .map(|file| file.row_id_range().unwrap().1)
                     .max()
                     .unwrap();
-                let row_range_start = group_start.max(shard_start);
-                let row_range_end = group_end.min(shard_end);
-                result.push(VindexIndexShard {
-                    partition: partition.clone(),
-                    partition_bytes: partition_bytes.clone(),
-                    files: group,
-                    row_range_start,
-                    row_range_end,
-                    snapshot_id,
-                    source_bucket,
-                    total_buckets,
-                    bucket_path: bucket_path.clone(),
-                });
+                // Coverage of this group clamped to the current shard cell. 
Then
+                // subtract the already-indexed ranges so the build only covers
+                // the gap. Because grid-clamp and gap-subtraction are both 
range
+                // intersections, applying the gap here is equivalent to 
btree's
+                // "exclude then split" -- and each surviving segment stays 
inside
+                // one shard cell, preserving per-shard row-id contiguity (the
+                // reader errors on a row-id gap within a shard).
+                let coverage_start = group_start.max(shard_start);
+                let coverage_end = group_end.min(shard_end);
+                let build_segments =
+                    exclude_row_ranges(&[RowRange::new(coverage_start, 
coverage_end)], indexed);
+                for seg in build_segments {
+                    result.push(VindexIndexShard {
+                        partition: partition.clone(),
+                        partition_bytes: partition_bytes.clone(),
+                        files: group.clone(),
+                        row_range_start: seg.from(),
+                        row_range_end: seg.to(),
+                        snapshot_id,
+                        source_bucket,
+                        total_buckets,
+                        bucket_path: bucket_path.clone(),
+                    });
+                }
             }
         }
     }
@@ -515,51 +543,6 @@ fn bucket_path(
     ))
 }
 
-async fn validate_existing_index_overlap(
-    table: &Table,
-    index_manifest_name: Option<&str>,
-    index_field_id: i32,
-    shards: &[VindexIndexShard],
-) -> Result<()> {
-    let Some(index_manifest_name) = index_manifest_name else {
-        return Ok(());
-    };
-    let path = format!(
-        "{}/manifest/{}",
-        table.location().trim_end_matches('/'),
-        index_manifest_name
-    );
-    let entries = IndexManifest::read(table.file_io(), &path).await?;
-    for entry in entries {
-        if entry.kind != FileKind::Add {
-            continue;
-        }
-        let Some(meta) = entry.index_file.global_index_meta else {
-            continue;
-        };
-        if meta.index_field_id != index_field_id {
-            continue;
-        }
-        if shards.iter().any(|shard| {
-            ranges_overlap(
-                meta.row_range_start,
-                meta.row_range_end,
-                shard.row_range_start,
-                shard.row_range_end,
-            )
-        }) {
-            return Err(Error::DataInvalid {
-                message: format!(
-                    "Existing global index file '{}' overlaps requested row 
range for field {}",
-                    entry.index_file.file_name, index_field_id
-                ),
-                source: None,
-            });
-        }
-    }
-    Ok(())
-}
-
 async fn extract_vectors(
     table: &Table,
     shard: &VindexIndexShard,
@@ -794,19 +777,19 @@ fn validate_vector_buffer(vectors: &[f32], row_count: 
i32, dimension: i32) -> Re
     Ok(())
 }
 
-fn ranges_overlap(left_start: i64, left_end: i64, right_start: i64, right_end: 
i64) -> bool {
-    left_start <= right_end && right_start <= left_end
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
     use crate::catalog::Identifier;
-    use crate::io::FileIOBuilder;
+    use crate::io::{FileIO, FileIOBuilder};
     use crate::spec::stats::BinaryTableStats;
-    use crate::spec::{ArrayType, FloatType, IntType, ManifestEntry, Schema, 
TableSchema};
+    use crate::spec::{
+        ArrayType, FloatType, IndexManifest, IntType, ManifestEntry, Schema, 
TableSchema,
+    };
+    use crate::table::TableWrite;
+    use crate::vindex::IVF_FLAT_IDENTIFIER;
     use arrow_array::builder::{Float32Builder, Int64Builder, ListBuilder};
-    use arrow_array::ArrayRef;
+    use arrow_array::{ArrayRef, Int32Array};
     use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
     use chrono::{DateTime, Utc};
     use std::sync::Arc;
@@ -876,6 +859,14 @@ mod tests {
     }
 
     fn plan(entries: Vec<ManifestEntry>, rows_per_shard: i64) -> 
Result<Vec<VindexIndexShard>> {
+        plan_with_indexed(entries, rows_per_shard, &[])
+    }
+
+    fn plan_with_indexed(
+        entries: Vec<ManifestEntry>,
+        rows_per_shard: i64,
+        indexed: &[RowRange],
+    ) -> Result<Vec<VindexIndexShard>> {
         let table = test_table(table_options(&rows_per_shard.to_string()));
         let core = CoreOptions::new(table.schema().options());
         plan_vindex_shards(
@@ -886,6 +877,7 @@ mod tests {
             1,
             entries,
             rows_per_shard,
+            indexed,
         )
     }
 
@@ -992,4 +984,506 @@ mod tests {
             matches!(err, Error::DataInvalid { message, .. } if 
message.contains("dimension mismatch"))
         );
     }
+
+    fn test_table_with_io(file_io: FileIO, table_path: &str, schema: Schema) 
-> Table {
+        Table::new(
+            file_io,
+            Identifier::new("default", "test_table"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        )
+    }
+
+    fn vindex_schema_builder(options: HashMap<String, String>) -> 
crate::spec::SchemaBuilder {
+        Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column(
+                "embedding",
+                
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+            )
+            .options(options)
+    }
+
+    fn vindex_e2e_options(rows_per_shard: &str) -> HashMap<String, String> {
+        let mut options = table_options(rows_per_shard);
+        // A small, valid IVF config so the (optional) native build can run; 
the
+        // no-op/incremental fix is exercised before or independently of it.
+        options.insert("ivf-flat.dimension".to_string(), "2".to_string());
+        options.insert("ivf-flat.nlist".to_string(), "2".to_string());
+        options
+    }
+
+    fn vindex_e2e_table(table_path: &str, rows_per_shard: &str) -> Table {
+        test_table_with_io(
+            FileIOBuilder::new("memory").build().unwrap(),
+            table_path,
+            vindex_schema_builder(vindex_e2e_options(rows_per_shard))
+                .build()
+                .unwrap(),
+        )
+    }
+
+    async fn setup_dirs(file_io: &FileIO, table_path: &str) {
+        file_io
+            .mkdirs(&format!("{table_path}/snapshot/"))
+            .await
+            .unwrap();
+        file_io
+            .mkdirs(&format!("{table_path}/manifest/"))
+            .await
+            .unwrap();
+    }
+
+    fn build_vector_batch(ids: Vec<i32>, vectors: Vec<Vec<f32>>) -> 
RecordBatch {
+        let element_field = Arc::new(ArrowField::new("element", 
ArrowDataType::Float32, true));
+        let mut vector_builder =
+            
ListBuilder::new(Float32Builder::new()).with_field(element_field.clone());
+        for vector in vectors {
+            for value in vector {
+                vector_builder.values().append_value(value);
+            }
+            vector_builder.append(true);
+        }
+        let schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, false),
+            ArrowField::new("embedding", ArrowDataType::List(element_field), 
true),
+        ]));
+        RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(Int32Array::from(ids)) as ArrayRef,
+                Arc::new(vector_builder.finish()) as ArrayRef,
+            ],
+        )
+        .unwrap()
+    }
+
+    async fn write_vectors(table: &Table, ids: Vec<i32>, vectors: 
Vec<Vec<f32>>) {
+        let mut table_write = TableWrite::new(table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&build_vector_batch(ids, vectors))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+    }
+
+    /// Commit a synthetic vindex `IndexFileMeta` covering `[start, end]` for
+    /// `field_id` directly into the index manifest, without invoking the 
native
+    /// builder. Mirrors the Lumina/btree tests so the incremental gap logic 
can
+    /// be exercised without a trained vector index. Writes the same 
`index_type`
+    /// (`ivf-flat`) the builder-under-test uses, so the gap helper matches it.
+    async fn commit_synthetic_vindex_index(table: &Table, field_id: i32, 
start: i64, end: i64) {
+        let synthetic = IndexFileMeta {
+            index_type: IVF_FLAT_IDENTIFIER.to_string(),
+            file_name: 
format!("vector-ivf-flat-synthetic-{start}-{end}.index"),
+            file_size: 1,
+            row_count: (end - start + 1) as i32,
+            deletion_vectors_ranges: None,
+            global_index_meta: Some(GlobalIndexMeta {
+                row_range_start: start,
+                row_range_end: end,
+                index_field_id: field_id,
+                extra_field_ids: None,
+                index_meta: None,
+            }),
+        };
+        let mut message = 
CommitMessage::new(BinaryRow::new(0).to_serialized_bytes(), 0, vec![]);
+        message.new_index_files = vec![synthetic];
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(vec![message])
+            .await
+            .unwrap();
+    }
+
+    async fn latest_vindex_index_files(table: &Table) -> Vec<IndexFileMeta> {
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let Some(index_manifest_name) = snapshot.index_manifest() else {
+            return Vec::new();
+        };
+        IndexManifest::read(
+            table.file_io(),
+            &snapshot_manager.manifest_path(index_manifest_name),
+        )
+        .await
+        .unwrap()
+        .into_iter()
+        .filter(|entry| {
+            entry.kind == FileKind::Add && entry.index_file.index_type == 
IVF_FLAT_IDENTIFIER
+        })
+        .map(|entry| entry.index_file)
+        .collect()
+    }
+
+    /// Row-id coverage of the committed data files, read back from the data
+    /// manifest (never hard-coded) and merged into contiguous ranges. Mirrors
+    /// how `execute` gathers `manifest_entries`.
+    async fn data_row_id_coverage(table: &Table) -> Vec<RowRange> {
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let entries = table
+            .new_read_builder()
+            .new_scan()
+            .with_scan_all_files()
+            .plan_manifest_entries(&snapshot)
+            .await
+            .unwrap();
+        let ranges = entries
+            .iter()
+            .filter(|entry| *entry.kind() == FileKind::Add)
+            .filter_map(|entry| {
+                entry
+                    .file()
+                    .row_id_range()
+                    .map(|(start, end)| RowRange::new(start, end))
+            })
+            .collect::<Vec<_>>();
+        crate::table::merge_row_ranges(ranges)
+    }
+
+    /// Second build with the whole coverage already indexed must be a clean
+    /// no-op (returns 0), not an overlap error. Reaches `Ok(0)` before the
+    /// native build, so it runs in CI without a trained index. This is the 
core
+    /// bug fix: today the second call errors with the overlap message.
+    #[tokio::test]
+    async fn vindex_second_build_without_new_data_is_noop() {
+        let table_path = "memory:/test_vindex_second_build_noop";
+        let table = vindex_e2e_table(table_path, "10");
+        setup_dirs(table.file_io(), table_path).await;
+
+        write_vectors(
+            &table,
+            vec![1, 2, 3],
+            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]],
+        )
+        .await;
+
+        // Fully index the coverage via a synthetic manifest entry.
+        let coverage = data_row_id_coverage(&table).await;
+        assert_eq!(coverage.len(), 1, "data must be one contiguous range");
+        let field_id = find_index_field(&table, "embedding").unwrap().id();
+        commit_synthetic_vindex_index(&table, field_id, coverage[0].from(), 
coverage[0].to()).await;
+
+        let names_before = latest_vindex_index_files(&table)
+            .await
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        assert!(!names_before.is_empty());
+
+        let built = table
+            .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
+            .with_index_column("embedding")
+            .execute()
+            .await
+            .unwrap();
+        assert_eq!(built, 0, "fully-indexed table must build nothing on 
re-run");
+
+        let names_after = latest_vindex_index_files(&table)
+            .await
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        assert_eq!(
+            names_before, names_after,
+            "re-run must not add or remove index manifest entries"
+        );
+    }
+
+    /// Real end-to-end incremental build. `paimon-vindex-core` is pure Rust 
and
+    /// trains/serializes an IVF-flat index in CI without a native lib, so this
+    /// asserts SUCCESS end-to-end (mirroring btree's incremental test): build 
#1
+    /// indexes the initial rows, an appended batch is indexed by build #2, 
every
+    /// new index file's row range lies entirely in the appended gap `[n, ..]`
+    /// (`n` derived from the manifest, never hard-coded), and build-#1's index
+    /// files are retained untouched (append-only). No overlap error, no 
tolerated
+    /// native-build failure -- the build must actually succeed.
+    #[tokio::test]
+    async fn vindex_incremental_build_indexes_only_new_rows() {
+        let table_path = "memory:/test_vindex_incremental";
+        let table = vindex_e2e_table(table_path, "10");
+        setup_dirs(table.file_io(), table_path).await;
+
+        // Build #1 over the initial batch via a real end-to-end build.
+        write_vectors(
+            &table,
+            vec![1, 2, 3],
+            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]],
+        )
+        .await;
+        let first_built = table
+            .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
+            .with_index_column("embedding")
+            .execute()
+            .await
+            .unwrap();
+        assert!(first_built > 0, "first build must index the initial rows");
+
+        // First appended row-id, derived from the data manifest (never 
hard-coded).
+        let indexed_coverage = data_row_id_coverage(&table).await;
+        assert_eq!(indexed_coverage.len(), 1);
+        let n = indexed_coverage[0].to() + 1;
+
+        let first_names = latest_vindex_index_files(&table)
+            .await
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+        assert!(!first_names.is_empty(), "build #1 must write index files");
+
+        // Append a second batch (new row-ids [n..]).
+        write_vectors(
+            &table,
+            vec![4, 5, 6],
+            vec![vec![2.0, 0.0], vec![0.0, 2.0], vec![2.0, 2.0]],
+        )
+        .await;
+
+        // End-to-end: build #2 must SUCCEED and index the appended rows.
+        let second_built = table
+            .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
+            .with_index_column("embedding")
+            .execute()
+            .await
+            .unwrap();
+        assert!(second_built > 0, "appended rows must be indexed");
+
+        let all_files = latest_vindex_index_files(&table).await;
+        let all_names = all_files
+            .iter()
+            .map(|f| f.file_name.clone())
+            .collect::<std::collections::BTreeSet<_>>();
+
+        // Every build-#1 file is still present (append-only, no 
rewrite/delete).
+        assert!(
+            first_names.iter().all(|name| all_names.contains(name)),
+            "build #1 index files must be retained untouched"
+        );
+
+        // Every build-#2 file covers only the appended gap [n, ..], never the
+        // already-indexed prefix.
+        let new_files = all_files
+            .iter()
+            .filter(|f| !first_names.contains(&f.file_name))
+            .collect::<Vec<_>>();
+        assert!(!new_files.is_empty(), "build #2 must add new index files");
+        for file in new_files {
+            let meta = file
+                .global_index_meta
+                .as_ref()
+                .expect("global index meta on new vindex file");
+            assert!(
+                meta.row_range_start >= n,
+                "new index file range must start at or after {n}, got [{}, 
{}]",
+                meta.row_range_start,
+                meta.row_range_end
+            );
+        }
+    }
+
+    /// A field that already carries a DIFFERENT index type (`lumina`) over an
+    /// overlapping row range must not block a vindex (`ivf-flat`) build on the
+    /// same field: the two indexes have distinct identities and coexist. 
Before
+    /// the full-identity fix, the overlap guard keyed only on field id + range
+    /// and spuriously rejected this build with the "overlaps requested row
+    /// range" error.
+    #[tokio::test]
+    async fn vindex_build_coexists_with_different_index_type_on_same_field() {
+        let table_path = "memory:/test_vindex_coexist_diff_type";
+        let table = vindex_e2e_table(table_path, "10");
+        setup_dirs(table.file_io(), table_path).await;
+
+        write_vectors(
+            &table,
+            vec![1, 2, 3],
+            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]],
+        )
+        .await;
+
+        // Pre-existing `lumina` index covering the full data range on the SAME
+        // field the vindex build will target.
+        let coverage = data_row_id_coverage(&table).await;
+        assert_eq!(coverage.len(), 1, "data must be one contiguous range");
+        let field_id = find_index_field(&table, "embedding").unwrap().id();
+        let lumina = IndexFileMeta {
+            index_type: "lumina".to_string(),
+            file_name: "lumina-synthetic-0.index".to_string(),
+            file_size: 1,
+            row_count: (coverage[0].to() - coverage[0].from() + 1) as i32,
+            deletion_vectors_ranges: None,
+            global_index_meta: Some(GlobalIndexMeta {
+                row_range_start: coverage[0].from(),
+                row_range_end: coverage[0].to(),
+                index_field_id: field_id,
+                extra_field_ids: None,
+                index_meta: None,
+            }),
+        };
+        let mut message = 
CommitMessage::new(BinaryRow::new(0).to_serialized_bytes(), 0, vec![]);
+        message.new_index_files = vec![lumina];
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(vec![message])
+            .await
+            .unwrap();
+
+        // Building `ivf-flat` on the same field must NOT trip the overlap 
guard.
+        // A native-build failure over the tiny synthetic dataset is tolerated;
+        // only the overlap error is forbidden.
+        let result = table
+            .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
+            .with_index_column("embedding")
+            .execute()
+            .await;
+        match result {
+            Ok(_) => {}
+            Err(Error::DataInvalid { message, .. }) => {
+                assert!(
+                    !message.contains("overlaps requested row range"),
+                    "vindex build must coexist with a different-type index on 
the same field; got: {message}"
+                );
+            }
+            Err(other) => panic!("unexpected error from vindex build: 
{other:?}"),
+        }
+    }
+
+    /// Regression: a first build (no existing index) must equal the pre-change
+    /// full build -- subtracting an empty `indexed` yields full coverage.
+    #[test]
+    fn vindex_first_build_indexes_full_coverage() {
+        let full = plan(vec![manifest_entry(data_file("a", Some(0), 25))], 
10).unwrap();
+        let gapped =
+            plan_with_indexed(vec![manifest_entry(data_file("a", Some(0), 
25))], 10, &[]).unwrap();
+        // Empty `indexed` must not alter the shard layout.
+        assert_eq!(
+            full.iter()
+                .map(|s| (s.row_range_start, s.row_range_end))
+                .collect::<Vec<_>>(),
+            gapped
+                .iter()
+                .map(|s| (s.row_range_start, s.row_range_end))
+                .collect::<Vec<_>>()
+        );
+        assert_eq!(
+            full.iter()
+                .map(|s| (s.row_range_start, s.row_range_end))
+                .collect::<Vec<_>>(),
+            vec![(0, 9), (10, 19), (20, 24)],
+            "first build must cover the full row range across shards"
+        );
+    }
+
+    /// Planner-level mid-coverage hole, mirroring btree/lumina: with a single
+    /// shard cell (rows_per_shard large enough to hold all data) the grid 
never
+    /// splits, so the only split is the indexed hole itself. An indexed range
+    /// strictly inside the data coverage must carve the build into exactly the
+    /// two contiguous segments on either side of the hole -- both bounds 
pinned,
+    /// and neither segment may span or touch the hole.
+    #[test]
+    fn vindex_plan_splits_gap_around_mid_coverage_indexed_hole() {
+        // Data row-ids [0, 9]; one shard cell [0, 99] so the grid never 
splits.
+        let n = 9;
+        let hole_start = 4;
+        let hole_end = 6;
+        let shards = plan_with_indexed(
+            vec![manifest_entry(data_file("a", Some(0), n + 1))],
+            100,
+            &[RowRange::new(hole_start, hole_end)],
+        )
+        .unwrap();
+
+        let ranges = shards
+            .iter()
+            .map(|s| (s.row_range_start, s.row_range_end))
+            .collect::<Vec<_>>();
+        // Exactly the two contiguous segments around the hole.
+        assert_eq!(
+            ranges,
+            vec![(0, hole_start - 1), (hole_end + 1, n)],
+            "mid-coverage hole must split into exactly the two segments around 
it"
+        );
+        // Every emitted range is contiguous and none spans or touches the 
hole.
+        for (start, end) in &ranges {
+            assert!(end >= start, "range must be non-empty: [{start}, {end}]");
+            assert!(
+                *end < hole_start || *start > hole_end,
+                "shard [{start}, {end}] must not overlap indexed hole 
[{hole_start}, {hole_end}]"
+            );
+        }
+        // Together the shards cover exactly coverage - indexed.
+        let expected = exclude_row_ranges(
+            &[RowRange::new(0, n)],
+            &[RowRange::new(hole_start, hole_end)],
+        )
+        .into_iter()
+        .map(|r| (r.from(), r.to()))
+        .collect::<Vec<_>>();
+        assert_eq!(
+            ranges, expected,
+            "shards must cover exactly coverage minus the indexed hole"
+        );
+    }
+
+    /// Planner-level incremental prefix. Strengthens
+    /// `vindex_incremental_build_indexes_only_new_rows`, which asserts only a
+    /// one-sided lower bound (`row_range_start >= n`): an indexed prefix [0, 
k]
+    /// must leave EXACTLY the suffix [k+1, N] on both bounds, split along the
+    /// shard grid, with nothing re-indexed inside the prefix.
+    #[test]
+    fn vindex_plan_incremental_prefix_leaves_suffix() {
+        // Data row-ids [0, 24], rows_per_shard = 10 -> cells 
[0,9],[10,19],[20,29].
+        // Indexed prefix [0, 9] fully fills the first cell, so the build must 
be
+        // exactly [10, 19] and [20, 24] (the suffix split along the grid).
+        let n = 24;
+        let k = 9; // prefix [0, k] == the first full shard cell
+        let shards = plan_with_indexed(
+            vec![manifest_entry(data_file("a", Some(0), n + 1))],
+            10,
+            &[RowRange::new(0, k)],
+        )
+        .unwrap();
+
+        let ranges = shards
+            .iter()
+            .map(|s| (s.row_range_start, s.row_range_end))
+            .collect::<Vec<_>>();
+        assert_eq!(
+            ranges,
+            vec![(k + 1, 19), (20, n)],
+            "indexed prefix must leave exactly the suffix, split along the 
shard grid"
+        );
+        // Both bounds pinned (this is what the one-sided existing check 
omits).
+        assert_eq!(ranges.first().unwrap().0, k + 1, "suffix must start at 
k+1");
+        assert_eq!(ranges.last().unwrap().1, n, "suffix must end at N");
+        // Contiguous, and no shard reaches back into the indexed prefix.
+        for pair in ranges.windows(2) {
+            assert_eq!(
+                pair[1].0,
+                pair[0].1 + 1,
+                "ranges must be contiguous: {:?} then {:?}",
+                pair[0],
+                pair[1]
+            );
+        }
+        for (start, end) in &ranges {
+            assert!(
+                *start > k,
+                "shard [{start}, {end}] must not re-index the prefix [0, {k}]"
+            );
+        }
+    }
 }

Reply via email to