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

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


The following commit(s) were added to refs/heads/master by this push:
     new de718276d6 [core] Support vector indexes on DV tables (#8930)
de718276d6 is described below

commit de718276d635850ff7fb7c134b38875c6370d550
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Aug 4 19:51:59 2026 +0800

    [core] Support vector indexes on DV tables (#8930)
---
 .../paimon/operation/FileStoreCommitImpl.java      |  72 +++++++++-
 .../source/AbstractDataEvolutionVectorRead.java    |  24 +++-
 .../paimon/operation/FileStoreCommitTest.java      |  31 +++++
 .../table/DataEvolutionDeletionVectorTest.java     |  62 ++++++++-
 .../table/source/VectorSearchBuilderTest.java      |  50 +++++++
 .../globalindex/GenericGlobalIndexBuilder.java     |   7 -
 ...enericGlobalIndexBuilderDeletionVectorTest.java |  58 ++++++++
 paimon-python/pypaimon/table/file_store_table.py   |  14 +-
 .../table/source/global_index_live_row_filter.py   |  28 +++-
 .../pypaimon/table/source/vector_search_read.py    |  93 ++++++++-----
 .../pypaimon/table/source/vector_search_scan.py    |  13 +-
 .../pypaimon/tests/table/file_store_table_test.py  |  20 +++
 .../pypaimon/tests/vector_search_filter_test.py    | 154 ++++++++++++++++++++-
 .../spark/read/SparkDataEvolutionVectorRead.java   |   1 +
 .../read/SparkDataEvolutionVectorReadTest.java     |  54 +++++++-
 15 files changed, 610 insertions(+), 71 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index bc22370c47..88d6083dec 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -340,7 +340,9 @@ public class FileStoreCommitImpl implements FileStoreCommit 
{
         int generatedSnapshot = 0;
         int attempts = 0;
 
-        ManifestEntryChanges changes = 
collectChanges(committable.fileCommittables());
+        List<CommitMessage> commitMessages = committable.fileCommittables();
+        ManifestEntryChanges changes = collectChanges(commitMessages);
+        Set<Pair<BinaryRow, Integer>> materializedBuckets = 
materializedBuckets(commitMessages);
         try {
             List<SimpleFileEntry> appendSimpleEntries =
                     SimpleFileEntry.from(changes.appendTableFiles);
@@ -393,10 +395,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                     || !changes.compactIndexFiles.isEmpty()) {
                 attempts +=
                         tryCommit(
-                                CommitChangesProvider.provider(
-                                        changes.compactTableFiles,
-                                        changes.compactChangelog,
-                                        changes.compactIndexFiles),
+                                compactChangesProvider(changes, 
materializedBuckets),
                                 committable.identifier(),
                                 committable.watermark(),
                                 committable.properties(),
@@ -767,6 +766,69 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         return changes;
     }
 
+    private Set<Pair<BinaryRow, Integer>> 
materializedBuckets(List<CommitMessage> commitMessages) {
+        if (!options.dataEvolutionEnabled() || 
!options.deletionVectorsEnabled()) {
+            return Collections.emptySet();
+        }
+
+        Set<Pair<BinaryRow, Integer>> result = new HashSet<>();
+        for (CommitMessage message : commitMessages) {
+            CommitMessageImpl commitMessage = (CommitMessageImpl) message;
+            if (commitMessage.compactIncrement().compactBefore().stream()
+                            .noneMatch(
+                                    file ->
+                                            !isBlobFile(file.fileName())
+                                                    && 
!isVectorStoreFile(file.fileName()))
+                    || commitMessage.compactIncrement().compactAfter().stream()
+                            .anyMatch(file -> file.firstRowId() != null)) {
+                continue;
+            }
+            result.add(Pair.of(commitMessage.partition(), 
commitMessage.bucket()));
+        }
+        return result;
+    }
+
+    @VisibleForTesting
+    CommitChangesProvider compactChangesProvider(
+            ManifestEntryChanges changes, Set<Pair<BinaryRow, Integer>> 
materializedBuckets) {
+        if (materializedBuckets.isEmpty()) {
+            return CommitChangesProvider.provider(
+                    changes.compactTableFiles, changes.compactChangelog, 
changes.compactIndexFiles);
+        }
+
+        return latestSnapshot -> {
+            List<IndexManifestEntry> indexFiles =
+                    changes.compactIndexFiles.stream()
+                            // Replace global-index deletions prepared against 
an older snapshot.
+                            .filter(
+                                    entry ->
+                                            entry.kind() != FileKind.DELETE
+                                                    || 
entry.indexFile().globalIndexMeta() == null
+                                                    || 
!materializedBuckets.contains(
+                                                            Pair.of(
+                                                                    
entry.partition(),
+                                                                    
entry.bucket())))
+                            .collect(Collectors.toList());
+
+            // This provider is invoked again after every optimistic-commit 
conflict. Scanning the
+            // latest snapshot here guarantees that an index committed 
concurrently is either
+            // deleted by this attempt or makes this attempt retry and is 
deleted by the next one.
+            if (latestSnapshot != null && latestSnapshot.indexManifest() != 
null) {
+                for (IndexManifestEntry entry :
+                        
indexManifestFile.read(latestSnapshot.indexManifest())) {
+                    if (entry.indexFile().globalIndexMeta() != null
+                            && materializedBuckets.contains(
+                                    Pair.of(entry.partition(), 
entry.bucket()))) {
+                        indexFiles.add(entry.toDeleteEntry());
+                    }
+                }
+            }
+
+            return new CommitChanges(
+                    changes.compactTableFiles, changes.compactChangelog, 
indexFiles);
+        };
+    }
+
     private int tryCommit(
             CommitChangesProvider changesProvider,
             long identifier,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
index 1f505cc128..75fbb2b5b7 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.table.source;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.data.InternalArray;
 import org.apache.paimon.data.InternalRow;
@@ -83,8 +84,8 @@ public abstract class AbstractDataEvolutionVectorRead 
implements Serializable {
     protected final DataField vectorColumn;
     protected final Map<String, String> options;
 
-    /** Snapshot the plan was built against; pins live-row filtering to it. */
-    @Nullable protected transient Snapshot planSnapshot;
+    /** Snapshot the plan was built against; pins filters and raw reads to it. 
*/
+    @Nullable protected Snapshot planSnapshot;
 
     private static final Comparator<long[]> WEAKEST_SCORE_FIRST =
             Comparator.<long[]>comparingDouble(a -> Float.intBitsToFloat((int) 
a[1]))
@@ -593,7 +594,7 @@ public abstract class AbstractDataEvolutionVectorRead 
implements Serializable {
     }
 
     private ReadBuilder newRawReadBuilder(RowType readType, boolean 
includeFilter) {
-        ReadBuilder readBuilder = 
table.newReadBuilder().withReadType(readType);
+        ReadBuilder readBuilder = 
rawReadTable().newReadBuilder().withReadType(readType);
         if (partitionFilter != null) {
             readBuilder.withPartitionFilter(partitionFilter);
         }
@@ -603,6 +604,23 @@ public abstract class AbstractDataEvolutionVectorRead 
implements Serializable {
         return readBuilder;
     }
 
+    private FileStoreTable rawReadTable() {
+        if (planSnapshot == null) {
+            return table;
+        }
+
+        Map<String, String> pinOptions = new HashMap<>();
+        pinOptions.put(
+                CoreOptions.SCAN_MODE.key(), 
CoreOptions.StartupMode.FROM_SNAPSHOT.toString());
+        pinOptions.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), 
String.valueOf(planSnapshot.id()));
+        pinOptions.put(CoreOptions.SCAN_VERSION.key(), null);
+        pinOptions.put(CoreOptions.SCAN_TAG_NAME.key(), null);
+        pinOptions.put(CoreOptions.SCAN_WATERMARK.key(), null);
+        pinOptions.put(CoreOptions.SCAN_TIMESTAMP.key(), null);
+        pinOptions.put(CoreOptions.SCAN_TIMESTAMP_MILLIS.key(), null);
+        return table.copyWithoutTimeTravel(pinOptions);
+    }
+
     protected static void splitSearchSplits(
             List<? extends VectorSearchSplit> splits,
             List<IndexVectorSearchSplit> indexSplits,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
index fb22df8c1f..6458f82fab 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
@@ -46,6 +46,7 @@ import org.apache.paimon.manifest.ManifestFile;
 import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.manifest.ManifestList;
 import org.apache.paimon.mergetree.compact.DeduplicateMergeFunction;
+import org.apache.paimon.operation.commit.CommitChanges;
 import org.apache.paimon.operation.commit.ConflictDetection;
 import org.apache.paimon.operation.commit.ManifestEntryChanges;
 import org.apache.paimon.operation.commit.RetryCommitResult;
@@ -912,6 +913,36 @@ public class FileStoreCommitTest {
         assertThat(file).isEmpty();
     }
 
+    @Test
+    public void 
testMaterializedCompactionOnlyRefreshesGlobalIndexInSameBucket() throws 
Exception {
+        TestFileStore store = createStore(false, 2);
+        BinaryRow partition = gen.getPartition(gen.next());
+        IndexManifestEntry materializedBucketDelete =
+                globalIndexDeleteEntry(partition, 0, 
"materialized-bucket-index");
+        IndexManifestEntry otherBucketDelete =
+                globalIndexDeleteEntry(partition, 1, "other-bucket-index");
+        ManifestEntryChanges changes = new ManifestEntryChanges(2);
+        changes.compactIndexFiles.add(materializedBucketDelete);
+        changes.compactIndexFiles.add(otherBucketDelete);
+
+        try (FileStoreCommitImpl commit = store.newCommit()) {
+            CommitChanges refreshed =
+                    commit.compactChangesProvider(
+                                    changes, 
Collections.singleton(Pair.of(partition, 0)))
+                            .provide(null);
+
+            
assertThat(refreshed.indexFiles).containsExactly(otherBucketDelete);
+        }
+    }
+
+    private static IndexManifestEntry globalIndexDeleteEntry(
+            BinaryRow partition, int bucket, String fileName) {
+        IndexFileMeta file =
+                new IndexFileMeta(
+                        "btree", fileName, 1, 1, new GlobalIndexMeta(0, 0, 0, 
null, null), null);
+        return new IndexManifestEntry(FileKind.DELETE, partition, bucket, 
file);
+    }
+
     @Test
     public void testWriteStats() throws Exception {
         TestFileStore store = createStore(false, 1, 
CoreOptions.ChangelogProducer.NONE);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionDeletionVectorTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionDeletionVectorTest.java
index df0b646cc8..5b40fe4cb9 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionDeletionVectorTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionDeletionVectorTest.java
@@ -37,6 +37,7 @@ import 
org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
 import org.apache.paimon.format.blob.BlobFileFormat;
 import org.apache.paimon.globalindex.IndexedSplit;
 import org.apache.paimon.index.DeletionVectorMeta;
+import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.io.CompactIncrement;
 import org.apache.paimon.io.DataFileMeta;
@@ -737,6 +738,31 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
                         "b|9|updated-9");
     }
 
+    @Test
+    public void 
testMaterializeCompactionDropsGlobalIndexCommittedAfterPreparation()
+            throws Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        writeBaseRows(table);
+        updateStructuredColumn(table);
+        commitDeletionVectors(table, DEFAULT_DV_SPECS);
+
+        Map<String, String> dynamicOptions = new HashMap<>();
+        dynamicOptions.put(CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "2");
+        
dynamicOptions.put(CoreOptions.DATA_EVOLUTION_COMPACTION_REWRITE_ROW_IDS.key(), 
"true");
+        FileStoreTable compactTable = table.copy(dynamicOptions);
+        List<CommitMessage> compactMessages = 
prepareDataEvolutionCompaction(compactTable, false);
+
+        String indexFile = "concurrent-global-index";
+        commitGlobalIndex(table, indexFile, FULL_RANGE);
+        assertThat(liveGlobalIndexFileNames(table)).containsExactly(indexFile);
+
+        commit(compactTable, compactMessages);
+
+        assertThat(liveGlobalIndexFileNames(table)).isEmpty();
+        assertThat(normalFileRowRanges(table)).containsExactly(new Range(15, 
24));
+    }
+
     @Test
     public void 
testMaterializeCompactionUsesRemainingSizeForLargeDeletedRange() throws 
Exception {
         createTableDefault();
@@ -1137,6 +1163,11 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
 
     private void compactDataEvolutionTable(FileStoreTable table, boolean 
compactBlob)
             throws Exception {
+        commit(table, prepareDataEvolutionCompaction(table, compactBlob));
+    }
+
+    private List<CommitMessage> prepareDataEvolutionCompaction(
+            FileStoreTable table, boolean compactBlob) throws Exception {
         Snapshot snapshot = table.latestSnapshot().get();
         DataEvolutionCompactCoordinator coordinator =
                 new DataEvolutionCompactCoordinator(table, compactBlob, false, 
snapshot);
@@ -1154,7 +1185,36 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
         commitMessages.addAll(
                 new DataEvolutionCompactionCommitPreparation(table, snapshot)
                         .prepare(commitMessages));
-        commit(table, commitMessages);
+        return commitMessages;
+    }
+
+    private void commitGlobalIndex(FileStoreTable table, String fileName, 
Range rowRange)
+            throws Exception {
+        IndexFileMeta indexFile =
+                new IndexFileMeta(
+                        "test-global-index",
+                        fileName,
+                        1,
+                        rowRange.count(),
+                        new GlobalIndexMeta(rowRange.from, rowRange.to, 0, 
null, null),
+                        null);
+        commit(
+                table,
+                Collections.singletonList(
+                        new CommitMessageImpl(
+                                BinaryRow.EMPTY_ROW,
+                                UNAWARE_BUCKET,
+                                null,
+                                
DataIncrement.indexIncrement(Collections.singletonList(indexFile)),
+                                CompactIncrement.emptyIncrement())));
+    }
+
+    private static List<String> liveGlobalIndexFileNames(FileStoreTable table) 
{
+        return table.store().newIndexFileHandler().scanEntries().stream()
+                .filter(entry -> entry.indexFile().globalIndexMeta() != null)
+                .map(entry -> entry.indexFile().fileName())
+                .sorted()
+                .collect(Collectors.toList());
     }
 
     private void commitDeletionVectors(FileStoreTable table, List<DvSpec> 
deletionVectorSpecs)
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
index 3713e0a38b..f287f1875e 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
@@ -19,6 +19,10 @@
 package org.apache.paimon.table.source;
 
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactCoordinator;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
+import 
org.apache.paimon.append.dataevolution.DataEvolutionCompactionCommitPreparation;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.GenericArray;
 import org.apache.paimon.data.GenericRow;
@@ -282,6 +286,52 @@ public class VectorSearchBuilderTest extends TableTestBase 
{
         assertThat(result.results()).contains(0L);
     }
 
+    @Test
+    public void testRawFallbackPinsDataReadToPlanSnapshot() throws Exception {
+        catalog.createTable(
+                identifier("vector_raw_search_pinned_snapshot"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true")
+                        .option(CoreOptions.VECTOR_INDEX_SEARCH_MODE.key(), 
"full")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("vector_raw_search_pinned_snapshot"));
+
+        writeVectors(table, new float[][] {{0.0f, 0.0f}, {1.0f, 0.0f}});
+        VectorSearchBuilder builder =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD_NAME);
+        VectorScan.Plan plan = builder.newVectorScan().scan();
+
+        commitDeletionVectors(table, 0L);
+        Map<String, String> compactOptions = new HashMap<>();
+        
compactOptions.put(CoreOptions.DATA_EVOLUTION_COMPACTION_REWRITE_ROW_IDS.key(), 
"true");
+        FileStoreTable compactTable = table.copy(compactOptions);
+        Snapshot compactSnapshot = compactTable.latestSnapshot().get();
+        DataEvolutionCompactCoordinator coordinator =
+                new DataEvolutionCompactCoordinator(compactTable, false, 
false, compactSnapshot);
+        List<DataEvolutionCompactTask> tasks = coordinator.plan();
+        assertThat(tasks)
+                .singleElement()
+                .extracting(DataEvolutionCompactTask::type)
+                
.isEqualTo(DataEvolutionCompactTask.TaskType.MATERIALIZE_DELETION);
+        List<CommitMessage> messages = new ArrayList<>();
+        for (DataEvolutionCompactTask task : tasks) {
+            messages.add(task.doCompact(compactTable, 
"test-vector-snapshot-pin"));
+        }
+        messages.addAll(
+                new DataEvolutionCompactionCommitPreparation(compactTable, 
compactSnapshot)
+                        .prepare(messages));
+        try (BatchTableCommit commit = 
compactTable.newBatchWriteBuilder().newCommit()) {
+            commit.commit(messages);
+        }
+
+        GlobalIndexResult result = builder.newVectorRead().read(plan);
+        assertThat(result.results()).containsExactlyInAnyOrder(0L, 1L);
+    }
+
     @Test
     public void testVectorLiveRowPlanningSkipsUnindexedDeletionVectors() 
throws Exception {
         catalog.createTable(
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
index b20c513cd0..2872ac591d 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
@@ -67,13 +67,6 @@ public class GenericGlobalIndexBuilder implements 
Serializable {
                         + "but table '%s' has bucket = %d.",
                 table.name(),
                 table.coreOptions().bucket());
-        checkArgument(
-                !table.coreOptions().deletionVectorsEnabled(),
-                "Generic global index does not support tables with deletion 
vectors enabled. "
-                        + "Table '%s' has 'deletion-vectors.enabled' = true, 
which may cause "
-                        + "deleted rows to be indexed.",
-                table.name());
-
         scanSnapshot = table.snapshotManager().latestSnapshot();
         if (scanSnapshot == null) {
             return Collections.emptyList();
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilderDeletionVectorTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilderDeletionVectorTest.java
new file mode 100644
index 0000000000..62de5eee68
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilderDeletionVectorTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.flink.globalindex;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.SnapshotManager;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests deletion-vector table support in {@link GenericGlobalIndexBuilder}. 
*/
+class GenericGlobalIndexBuilderDeletionVectorTest {
+
+    private static FileStoreTable table(boolean deletionVectorsEnabled) {
+        FileStoreTable table = mock(FileStoreTable.class);
+        CoreOptions options = mock(CoreOptions.class);
+        when(options.bucket()).thenReturn(-1);
+        
when(options.deletionVectorsEnabled()).thenReturn(deletionVectorsEnabled);
+        when(table.coreOptions()).thenReturn(options);
+        when(table.name()).thenReturn("T");
+        SnapshotManager snapshotManager = mock(SnapshotManager.class);
+        when(snapshotManager.latestSnapshot()).thenReturn(null);
+        when(table.snapshotManager()).thenReturn(snapshotManager);
+        return table;
+    }
+
+    @Test
+    void testGlobalIndexAllowedOnDeletionVectorTable() {
+        assertThatCode(() -> new GenericGlobalIndexBuilder(table(true)).scan())
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void testGlobalIndexAllowedWithoutDeletionVectors() {
+        assertThatCode(() -> new 
GenericGlobalIndexBuilder(table(false)).scan())
+                .doesNotThrowAnyException();
+    }
+}
diff --git a/paimon-python/pypaimon/table/file_store_table.py 
b/paimon-python/pypaimon/table/file_store_table.py
index 4bc9b1c048..4181e3ae7c 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -503,6 +503,13 @@ class FileStoreTable(Table):
             raise ValueError(f"Unsupported bucket mode: {bucket_mode}")
 
     def copy(self, options: dict) -> 'FileStoreTable':
+        return self._copy(options, resolve_time_travel=True)
+
+    def copy_without_time_travel(self, options: dict) -> 'FileStoreTable':
+        """Copy this table while preserving its already resolved schema."""
+        return self._copy(options, resolve_time_travel=False)
+
+    def _copy(self, options: dict, resolve_time_travel: bool) -> 
'FileStoreTable':
         if CoreOptions.BUCKET.key() in options and 
int(options.get(CoreOptions.BUCKET.key())) != self.options.bucket():
             raise ValueError("Cannot change bucket number")
         new_options = CoreOptions.copy(self.options).options.to_map()
@@ -514,9 +521,10 @@ class FileStoreTable(Table):
 
         new_table_schema = self.table_schema.copy(new_options=new_options)
 
-        time_travel_schema = self._try_time_travel(Options(new_options))
-        if time_travel_schema is not None:
-            new_table_schema = time_travel_schema
+        if resolve_time_travel:
+            time_travel_schema = self._try_time_travel(Options(new_options))
+            if time_travel_schema is not None:
+                new_table_schema = time_travel_schema
 
         # Re-encode the branch into the identifier when the option changes, so
         # current_branch() and any catalog-routed snapshot commit see the
diff --git 
a/paimon-python/pypaimon/table/source/global_index_live_row_filter.py 
b/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
index 4e7d2bfcdd..af96b85e5b 100644
--- a/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
+++ b/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
@@ -19,6 +19,7 @@
 
 from typing import Optional
 
+from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.deletionvectors.deletion_vector import DeletionVector
 from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.split import DataSplit
@@ -26,8 +27,8 @@ from pypaimon.utils.range import Range
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
 
-def live_rows(table, partition_filter=None) -> Optional[RoaringBitmap64]:
-    """Return current live global row ids for deletion-vector tables.
+def live_rows(table, partition_filter=None, snapshot=None) -> 
Optional[RoaringBitmap64]:
+    """Return live global row ids at ``snapshot`` for deletion-vector tables.
 
     ``None`` means no live-row filter is needed. This keeps tables without
     deletion vectors on the old zero-overhead path.
@@ -39,7 +40,8 @@ def live_rows(table, partition_filter=None) -> 
Optional[RoaringBitmap64]:
             or not deletion_vectors_enabled(False)):
         return None
 
-    read_builder = table.new_read_builder()
+    read_table = table_at_snapshot(table, snapshot)
+    read_builder = read_table.new_read_builder()
     if partition_filter is not None:
         read_builder = read_builder.with_partition_filter(partition_filter)
 
@@ -56,10 +58,28 @@ def live_rows(table, partition_filter=None) -> 
Optional[RoaringBitmap64]:
     # Phase 2: subtract each DV. Ranges are all unioned first (no re-add), and
     # peak memory stays at one DV at a time.
     for split in data_splits:
-        _subtract_deleted_rows(table, rows, split)
+        _subtract_deleted_rows(read_table, rows, split)
     return rows
 
 
+def table_at_snapshot(table, snapshot):
+    """Return a table pinned to ``snapshot``, clearing conflicting time 
travel."""
+    if snapshot is None:
+        return table
+
+    pin_options = {
+        CoreOptions.SCAN_MODE.key(): "from-snapshot",
+        CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot.id),
+    }
+    for option in (CoreOptions.SCAN_TAG_NAME,
+                   CoreOptions.SCAN_WATERMARK,
+                   CoreOptions.SCAN_TIMESTAMP,
+                   CoreOptions.SCAN_TIMESTAMP_MILLIS):
+        if option.key() in table.table_schema.options:
+            pin_options[option.key()] = None
+    return table.copy_without_time_travel(pin_options)
+
+
 def for_range(live_row_ids: Optional[RoaringBitmap64],
               from_: int, to: int) -> Optional[RoaringBitmap64]:
     if live_row_ids is None:
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 84b0ae7c00..199dc42c93 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -41,11 +41,14 @@ class VectorSearchRead(ABC):
 
     def read_plan(self, plan):
         # type: (VectorSearchScanPlan) -> GlobalIndexResult
-        return self.read(plan.splits())
+        return self._read(plan.splits(), plan.snapshot())
 
-    @abstractmethod
     def read(self, splits):
         # type: (List[VectorSearchSplit]) -> GlobalIndexResult
+        return self._read(splits, None)
+
+    @abstractmethod
+    def _read(self, splits, snapshot):
         pass
 
 
@@ -54,11 +57,14 @@ class BatchVectorSearchRead(ABC):
 
     def read_batch_plan(self, plan):
         # type: (VectorSearchScanPlan) -> List[GlobalIndexResult]
-        return self.read_batch(plan.splits())
+        return self._read_batch(plan.splits(), plan.snapshot())
 
-    @abstractmethod
     def read_batch(self, splits):
         # type: (List[VectorSearchSplit]) -> List[GlobalIndexResult]
+        return self._read_batch(splits, None)
+
+    @abstractmethod
+    def _read_batch(self, splits, snapshot):
         pass
 
 
@@ -81,15 +87,15 @@ class AbstractVectorSearchReadImpl:
         self._partition_filter = partition_filter
         self._options = dict(options or {})
 
-    def _pre_filters(self, splits):
+    def _pre_filters(self, splits, snapshot=None):
         # type: (list) -> List[RoaringBitmap64]
         """Evaluate live-row/scalar filters and return one bitmap per index 
split."""
         if not splits:
             return []
 
         live_rows = global_index_live_row_filter.live_rows(
-            self._table, self._partition_filter)
-        matched_rows = self._scalar_matched_rows(splits)
+            self._table, self._partition_filter, snapshot)
+        matched_rows = self._scalar_matched_rows(splits, snapshot)
         if live_rows is None and matched_rows is None:
             return []
 
@@ -110,7 +116,7 @@ class AbstractVectorSearchReadImpl:
                 has_filter = True
         return include_row_ids if has_filter else []
 
-    def _scalar_matched_rows(self, splits):
+    def _scalar_matched_rows(self, splits, snapshot=None):
         """Evaluate scalar indexes and return matching global row ids."""
         if self._filter is None:
             return None
@@ -133,6 +139,7 @@ class AbstractVectorSearchReadImpl:
             self._table,
             index_files=scalar_files,
             partition_filter=self._partition_filter,
+            snapshot=snapshot,
         )
         if scanner is None:
             return RoaringBitmap64()
@@ -144,9 +151,9 @@ class AbstractVectorSearchReadImpl:
         finally:
             scanner.close()
 
-    def _pre_filter(self, splits):
+    def _pre_filter(self, splits, snapshot=None):
         # Backwards-compatible helper used by older tests/callers.
-        pre_filters = self._pre_filters(splits)
+        pre_filters = self._pre_filters(splits, snapshot)
         if not pre_filters:
             return None
         merged = RoaringBitmap64()
@@ -157,7 +164,7 @@ class AbstractVectorSearchReadImpl:
                 merged = RoaringBitmap64.or_(merged, bitmap)
         return merged
 
-    def _raw_pre_filter(self, splits):
+    def _raw_pre_filter(self, splits, snapshot=None):
         if self._filter is None:
             return None
         raw_rows = _bitmap_of_ranges(_raw_row_ranges(splits))
@@ -180,6 +187,7 @@ class AbstractVectorSearchReadImpl:
             self._table,
             index_files=scalar_files,
             partition_filter=self._partition_filter,
+            snapshot=snapshot,
         )
         if scanner is None:
             return None
@@ -249,12 +257,12 @@ class AbstractVectorSearchReadImpl:
 
     def _read_raw_search(self, raw_row_ranges, pre_filter, query_vector,
                          index_type=None, include_filter=True,
-                         score_candidates=None):
+                         score_candidates=None, snapshot=None):
         raw_row_ranges = _filtered_raw_row_ranges(raw_row_ranges, pre_filter)
         if not raw_row_ranges:
             return DictBasedScoredIndexResult({})
 
-        table = self._read_raw_arrow(raw_row_ranges, include_filter)
+        table = self._read_raw_arrow(raw_row_ranges, include_filter, snapshot)
         if table is None or table.num_rows == 0:
             return DictBasedScoredIndexResult({})
 
@@ -278,17 +286,17 @@ class AbstractVectorSearchReadImpl:
             )
         return _scored_result(top_k_heap)
 
-    def _read_raw_vectors(self, candidates, include_filter=True):
+    def _read_raw_vectors(self, candidates, include_filter=True, 
snapshot=None):
         return self._read_raw_candidate_vectors(
-            candidates.to_range_list(), candidates, include_filter)
+            candidates.to_range_list(), candidates, include_filter, snapshot)
 
     def _read_raw_candidate_vectors(self, raw_row_ranges, candidates,
-                                    include_filter=True):
+                                    include_filter=True, snapshot=None):
         raw_row_ranges = _filtered_raw_row_ranges(raw_row_ranges, None)
         if not raw_row_ranges:
             return {}
 
-        table = self._read_raw_arrow(raw_row_ranges, include_filter)
+        table = self._read_raw_arrow(raw_row_ranges, include_filter, snapshot)
         if table is None or table.num_rows == 0:
             return {}
 
@@ -303,8 +311,10 @@ class AbstractVectorSearchReadImpl:
             raw_vectors[row_id] = _to_vector_list(stored)
         return raw_vectors
 
-    def _read_raw_arrow(self, raw_row_ranges, include_filter):
-        read_builder = self._table.new_read_builder()
+    def _read_raw_arrow(self, raw_row_ranges, include_filter, snapshot=None):
+        read_table = global_index_live_row_filter.table_at_snapshot(
+            self._table, snapshot)
+        read_builder = read_table.new_read_builder()
         if self._partition_filter is not None:
             read_builder = read_builder.with_partition_filter(
                 self._partition_filter)
@@ -331,17 +341,20 @@ class AbstractVectorSearchReadImpl:
             )
         return _scored_result(top_k_heap)
 
-    def _read_raw_refine_search(self, candidates, query_vector, 
index_type=None):
+    def _read_raw_refine_search(self, candidates, query_vector, 
index_type=None,
+                                snapshot=None):
         return self._read_raw_candidate_search(
             candidates.to_range_list(),
             candidates,
             query_vector,
             index_type,
             include_filter=False,
+            snapshot=snapshot,
         )
 
     def _read_raw_candidate_search(self, raw_row_ranges, candidates, 
query_vector,
-                                   index_type=None, include_filter=False):
+                                   index_type=None, include_filter=False,
+                                   snapshot=None):
         return self._read_raw_search(
             raw_row_ranges,
             None,
@@ -349,6 +362,7 @@ class AbstractVectorSearchReadImpl:
             index_type,
             include_filter=include_filter,
             score_candidates=candidates,
+            snapshot=snapshot,
         )
 
     def _raw_search_projection(self, include_filter):
@@ -390,7 +404,8 @@ class AbstractVectorSearchReadImpl:
             return self._limit
         return self._limit * refine_factor
 
-    def _maybe_rerank_indexed_result(self, result, index_type, query_vector):
+    def _maybe_rerank_indexed_result(self, result, index_type, query_vector,
+                                     snapshot=None):
         if (self._configured_refine_factor(index_type) == 0 or
                 result.results().is_empty()):
             return result
@@ -399,9 +414,11 @@ class AbstractVectorSearchReadImpl:
             candidates.results(),
             query_vector,
             index_type,
+            snapshot,
         )
 
-    def _maybe_rerank_indexed_results(self, results, index_type, 
query_vectors):
+    def _maybe_rerank_indexed_results(self, results, index_type, query_vectors,
+                                      snapshot=None):
         if self._configured_refine_factor(index_type) == 0:
             return results
 
@@ -414,7 +431,8 @@ class AbstractVectorSearchReadImpl:
         if union_candidates.is_empty():
             return candidates
 
-        raw_vectors = self._read_raw_vectors(union_candidates, 
include_filter=False)
+        raw_vectors = self._read_raw_vectors(
+            union_candidates, include_filter=False, snapshot=snapshot)
         metric = _raw_search_metric(
             self._table, self._vector_column, self._options, index_type)
         return [
@@ -458,8 +476,7 @@ class DataEvolutionVectorRead(AbstractVectorSearchReadImpl, 
VectorSearchRead):
                          options=options)
         self._query_vector = query_vector
 
-    def read(self, splits):
-        # type: (List[VectorSearchSplit]) -> GlobalIndexResult
+    def _read(self, splits, snapshot):
         index_splits, raw_splits = _split_search_splits(splits)
         if not index_splits and not raw_splits:
             return GlobalIndexResult.create_empty()
@@ -467,20 +484,21 @@ class 
DataEvolutionVectorRead(AbstractVectorSearchReadImpl, VectorSearchRead):
         indexed = (
             DictBasedScoredIndexResult({})
             if not index_splits
-            else self._read_indexed(index_splits, self._query_vector)
+            else self._read_indexed(index_splits, self._query_vector, snapshot)
         )
         raw_result = self._read_raw_search(
             _raw_row_ranges(raw_splits),
-            self._raw_pre_filter(raw_splits),
+            self._raw_pre_filter(raw_splits, snapshot),
             self._query_vector,
             _raw_search_index_type(raw_splits),
+            snapshot=snapshot,
         )
         return indexed.or_(raw_result).top_k(self._limit)
 
-    def _read_indexed(self, splits, query_vector):
+    def _read_indexed(self, splits, query_vector, snapshot):
         index_type = _vector_index_type(splits)
         search_limit = self._indexed_search_limit(index_type)
-        pre_filters = self._pre_filters(splits)
+        pre_filters = self._pre_filters(splits, snapshot)
         futures = [
             self._eval(
                 split.row_range_start, split.row_range_end,
@@ -504,7 +522,8 @@ class DataEvolutionVectorRead(AbstractVectorSearchReadImpl, 
VectorSearchRead):
                         merged_scores[row_id] = score_getter(row_id)
 
         indexed = DictBasedScoredIndexResult(merged_scores).top_k(search_limit)
-        return self._maybe_rerank_indexed_result(indexed, index_type, 
query_vector)
+        return self._maybe_rerank_indexed_result(
+            indexed, index_type, query_vector, snapshot)
 
 
 class BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
@@ -519,8 +538,7 @@ class 
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
                          options=options)
         self._query_vectors = list(query_vectors)
 
-    def read_batch(self, splits):
-        # type: (List[VectorSearchSplit]) -> List[GlobalIndexResult]
+    def _read_batch(self, splits, snapshot):
         n = len(self._query_vectors)
         index_splits, raw_splits = _split_search_splits(splits)
         if not index_splits and not raw_splits:
@@ -530,7 +548,7 @@ class 
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
         # passing that split's pre-filter. Each future returns n per-query 
results.
         index_type = _vector_index_type(index_splits)
         search_limit = self._indexed_search_limit(index_type)
-        pre_filters = self._pre_filters(index_splits)
+        pre_filters = self._pre_filters(index_splits, snapshot)
         futures = [
             self._eval_batch(
                 split.row_range_start, split.row_range_end,
@@ -561,16 +579,17 @@ class 
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
             for i in range(n)
         ]
         indexed_results = self._maybe_rerank_indexed_results(
-            indexed_results, index_type, self._query_vectors)
+            indexed_results, index_type, self._query_vectors, snapshot)
 
         # Each query: merge indexed results with the raw (brute-force) 
fallback.
-        raw_pre_filter = self._raw_pre_filter(raw_splits)
+        raw_pre_filter = self._raw_pre_filter(raw_splits, snapshot)
         raw_ranges = _raw_row_ranges(raw_splits)
         raw_index_type = _raw_search_index_type(raw_splits)
         results = []
         for i in range(n):
             raw = self._read_raw_search(
-                raw_ranges, raw_pre_filter, self._query_vectors[i], 
raw_index_type)
+                raw_ranges, raw_pre_filter, self._query_vectors[i], 
raw_index_type,
+                snapshot=snapshot)
             results.append(indexed_results[i].or_(raw).top_k(self._limit))
         return results
 
diff --git a/paimon-python/pypaimon/table/source/vector_search_scan.py 
b/paimon-python/pypaimon/table/source/vector_search_scan.py
index 7e002064db..c97c173f23 100644
--- a/paimon-python/pypaimon/table/source/vector_search_scan.py
+++ b/paimon-python/pypaimon/table/source/vector_search_scan.py
@@ -25,7 +25,6 @@ from 
pypaimon.globalindex.data_evolution_global_index_coverage import DataEvolut
 from pypaimon.table.source.vector_search_split import (
     IndexVectorSearchSplit,
     RawVectorSearchSplit,
-    VectorSearchSplit,
 )
 from pypaimon.utils.range import Range
 
@@ -33,14 +32,18 @@ from pypaimon.utils.range import Range
 class VectorSearchScanPlan:
     """Plan of vector search scan."""
 
-    def __init__(self, splits):
-        # type: (List[VectorSearchSplit]) -> None
+    def __init__(self, splits, snapshot=None):
+        # type: (list, object) -> None
         self._splits = splits
+        self._snapshot = snapshot
 
     def splits(self):
-        # type: () -> List[VectorSearchSplit]
+        # type: () -> list
         return self._splits
 
+    def snapshot(self):
+        return self._snapshot
+
 
 class VectorSearchScan(ABC):
     """Vector search scan to scan index files."""
@@ -213,7 +216,7 @@ class DataEvolutionVectorScan(VectorSearchScan):
                 )
             )
 
-        return VectorSearchScanPlan(splits)
+        return VectorSearchScanPlan(splits, snapshot)
 
 
 def _has_intersection(ranges, row_range):
diff --git a/paimon-python/pypaimon/tests/table/file_store_table_test.py 
b/paimon-python/pypaimon/tests/table/file_store_table_test.py
index 9690a550b2..0786984af8 100644
--- a/paimon-python/pypaimon/tests/table/file_store_table_test.py
+++ b/paimon-python/pypaimon/tests/table/file_store_table_test.py
@@ -19,6 +19,7 @@ import os
 import shutil
 import tempfile
 import unittest
+from unittest import mock
 
 import pyarrow as pa
 
@@ -84,6 +85,25 @@ class FileStoreTableTest(unittest.TestCase):
 
         self.assertIn("Cannot change bucket number", str(context.exception))
 
+    def test_copy_without_time_travel_preserves_resolved_schema(self):
+        current_schema = self.table.table_schema
+        with mock.patch.object(
+                self.table,
+                "_try_time_travel",
+                side_effect=AssertionError("must not resolve time travel 
again")):
+            copied_table = self.table.copy_without_time_travel({
+                CoreOptions.SCAN_MODE.key(): "from-snapshot",
+                CoreOptions.SCAN_SNAPSHOT_ID.key(): "1",
+            })
+
+        self.assertEqual(current_schema.fields, 
copied_table.table_schema.fields)
+        self.assertEqual(current_schema.id, copied_table.table_schema.id)
+        self.assertEqual(
+            "1",
+            copied_table.table_schema.options[
+                CoreOptions.SCAN_SNAPSHOT_ID.key()],
+        )
+
     def test_consumer_manager(self):
         """Test that FileStoreTable has consumer_manager method."""
         # Get consumer_manager
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 9b861a626c..8cd2d7e6a2 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -116,6 +116,12 @@ class _StubTable:
                 return _G()
         return _P()
 
+    def copy(self, options):
+        return self
+
+    def copy_without_time_travel(self, options):
+        return self
+
     def new_vector_search_builder(self):
         from pypaimon.table.source.vector_search_builder import (
             VectorSearchBuilderImpl,
@@ -289,6 +295,11 @@ def _install_raw_full_text_read_builder(table, 
text_column_name, row_id_to_text,
 def _patch_snapshot(testcase, entries, snapshot=None):
     """Stub IndexFileHandler.scan + snapshot resolution."""
 
+    if snapshot is None:
+        snapshot = types.SimpleNamespace(id=1)
+    elif not hasattr(snapshot, "id"):
+        snapshot.id = 1
+
     mock.patch.stopall()
     for attr in ("_scan_patch", "_travel_patch"):
         patcher = getattr(testcase, attr, None)
@@ -309,7 +320,7 @@ def _patch_snapshot(testcase, entries, snapshot=None):
     testcase._scan_patch.start()
     testcase._travel_patch = mock.patch(
         
"pypaimon.snapshot.time_travel_util.TimeTravelUtil.try_travel_to_snapshot",
-        return_value=snapshot if snapshot is not None else object())
+        return_value=snapshot)
     testcase._travel_patch.start()
 
 
@@ -395,8 +406,13 @@ class GlobalIndexLiveRowFilterTest(unittest.TestCase):
 
         class _Table:
             options = _Options()
+            table_schema = _StubSchema()
             file_io = object()
 
+            def copy_without_time_travel(self_inner, options):
+                calls["copy_options"] = options
+                return self_inner
+
             def new_read_builder(self_inner):
                 calls["new_read_builder"] = True
                 return _Builder()
@@ -409,14 +425,18 @@ class GlobalIndexLiveRowFilterTest(unittest.TestCase):
                 return [1, 3]
 
         partition_filter = object()
+        snapshot = types.SimpleNamespace(id=3)
+        table = _Table()
         with mock.patch(
                 "pypaimon.table.source.global_index_live_row_filter."
                 "DeletionVector.read",
                 return_value=_DeletionVector()) as read:
             rows = global_index_live_row_filter.live_rows(
-                _Table(), partition_filter)
+                table, partition_filter, snapshot)
 
         read.assert_called_once_with(_Table.file_io, deletion_file)
+        self.assertEqual("from-snapshot", calls["copy_options"]["scan.mode"])
+        self.assertEqual("3", calls["copy_options"]["scan.snapshot-id"])
         self.assertIs(partition_filter, calls["partition_filter"])
         self.assertTrue(calls["new_read_builder"])
         self.assertTrue(calls["new_scan"])
@@ -938,7 +958,8 @@ class VectorSearchFilterTest(unittest.TestCase):
         ]
         self.table = _StubTable(fields=[self.id_field, self.embedding_field],
                                 entries=self.entries)
-        _patch_snapshot(self, self.entries)
+        self.snapshot = types.SimpleNamespace(id=1, next_row_id=10)
+        _patch_snapshot(self, self.entries, self.snapshot)
 
     def tearDown(self):
         mock.patch.stopall()
@@ -1009,13 +1030,20 @@ class VectorSearchFilterTest(unittest.TestCase):
             return _FakeReader()
 
         with mock.patch(
+                "pypaimon.table.source.vector_search_read."
+                "global_index_live_row_filter.live_rows",
+                return_value=None) as live_rows_fn, \
+             mock.patch(
                 
"pypaimon.globalindex.data_evolution_global_index_scanner.DataEvolutionGlobalIndexScanner.create",
-                return_value=scanner), \
+                return_value=scanner) as scanner_factory, \
              mock.patch(
                 
"pypaimon.table.source.vector_search_read._create_vector_reader",
                 side_effect=_capture_create):
             
self._builder(filter_pred).new_vector_search_read().read_plan(scan_plan)
 
+        self.assertIs(self.snapshot, scan_plan.snapshot())
+        live_rows_fn.assert_called_once_with(self.table, None, self.snapshot)
+        self.assertIs(self.snapshot, 
scanner_factory.call_args.kwargs["snapshot"])
         # Pre-filter happened once with our filter.
         self.assertEqual(1, scanner.scan.call_count)
         self.assertIs(filter_pred, scanner.scan.call_args[0][0])
@@ -2753,6 +2781,124 @@ class VectorSearchManySplitsTest(unittest.TestCase):
         self.assertEqual(["split"], calls["splits"])
         self.assertEqual([5], sorted(list(result.results())))
 
+    def test_read_plan_pins_raw_search_to_planned_snapshot(self):
+        from pypaimon.table.source.vector_search_read import 
DataEvolutionVectorRead
+        from pypaimon.table.source.vector_search_scan import 
VectorSearchScanPlan
+        from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
+
+        snapshot = types.SimpleNamespace(id=7)
+        embedding_field = _field(1, "embedding", "FLOAT")
+        table = _StubTable(fields=[embedding_field], entries=[])
+        read_table = _StubTable(fields=[embedding_field], entries=[])
+        _install_raw_vector_read_builder(
+            read_table, "embedding", {0: [1.0]})
+        table.copy_without_time_travel = mock.Mock(return_value=read_table)
+        plan = VectorSearchScanPlan(
+            [RawVectorSearchSplit([Range(0, 0)], [], "ivf-flat")],
+            snapshot,
+        )
+
+        result = DataEvolutionVectorRead(
+            table,
+            limit=1,
+            vector_column=embedding_field,
+            query_vector=[1.0],
+        ).read_plan(plan)
+
+        table.copy_without_time_travel.assert_called_once_with({
+            CoreOptions.SCAN_MODE.key(): "from-snapshot",
+            CoreOptions.SCAN_SNAPSHOT_ID.key(): "7",
+        })
+        self.assertEqual([0], sorted(list(result.results())))
+
+    def test_read_does_not_reuse_snapshot_from_previous_plan(self):
+        from pypaimon.table.source.vector_search_read import 
DataEvolutionVectorRead
+        from pypaimon.table.source.vector_search_scan import 
VectorSearchScanPlan
+        from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
+
+        snapshot = types.SimpleNamespace(id=7)
+        embedding_field = _field(1, "embedding", "FLOAT")
+        table = _StubTable(fields=[embedding_field], entries=[])
+        planned_table = _StubTable(fields=[embedding_field], entries=[])
+        _install_raw_vector_read_builder(table, "embedding", {1: [1.0]})
+        _install_raw_vector_read_builder(planned_table, "embedding", {0: 
[1.0]})
+        table.copy_without_time_travel = mock.Mock(return_value=planned_table)
+        split = RawVectorSearchSplit([Range(0, 1)], [], "ivf-flat")
+        reader = DataEvolutionVectorRead(
+            table,
+            limit=1,
+            vector_column=embedding_field,
+            query_vector=[1.0],
+        )
+
+        reader.read_plan(VectorSearchScanPlan([split], snapshot))
+        table.copy_without_time_travel.reset_mock()
+        result = reader.read([split])
+
+        table.copy_without_time_travel.assert_not_called()
+        self.assertEqual([1], sorted(list(result.results())))
+
+    def test_read_batch_does_not_reuse_snapshot_from_previous_plan(self):
+        from pypaimon.table.source.vector_search_read import 
BatchVectorSearchReadImpl
+        from pypaimon.table.source.vector_search_scan import 
VectorSearchScanPlan
+        from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
+
+        snapshot = types.SimpleNamespace(id=7)
+        embedding_field = _field(1, "embedding", "FLOAT")
+        table = _StubTable(fields=[embedding_field], entries=[])
+        planned_table = _StubTable(fields=[embedding_field], entries=[])
+        _install_raw_vector_read_builder(table, "embedding", {1: [1.0]})
+        _install_raw_vector_read_builder(planned_table, "embedding", {0: 
[1.0]})
+        table.copy_without_time_travel = mock.Mock(return_value=planned_table)
+        split = RawVectorSearchSplit([Range(0, 1)], [], "ivf-flat")
+        reader = BatchVectorSearchReadImpl(
+            table,
+            limit=1,
+            vector_column=embedding_field,
+            query_vectors=[[1.0]],
+        )
+
+        reader.read_batch_plan(VectorSearchScanPlan([split], snapshot))
+        table.copy_without_time_travel.reset_mock()
+        results = reader.read_batch([split])
+
+        table.copy_without_time_travel.assert_not_called()
+        self.assertEqual([1], sorted(list(results[0].results())))
+
+    def test_read_plan_clears_conflicting_time_travel_options(self):
+        from pypaimon.table.source.vector_search_read import 
DataEvolutionVectorRead
+        from pypaimon.table.source.vector_search_scan import 
VectorSearchScanPlan
+        from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
+
+        snapshot = types.SimpleNamespace(id=7)
+        embedding_field = _field(1, "embedding", "FLOAT")
+        table = _StubTable(fields=[embedding_field], entries=[])
+        table.table_schema.options = {
+            CoreOptions.SCAN_TAG_NAME.key(): "tag-1",
+            CoreOptions.SCAN_TIMESTAMP.key(): "2026-08-03 12:00:00",
+        }
+        read_table = _StubTable(fields=[embedding_field], entries=[])
+        _install_raw_vector_read_builder(
+            read_table, "embedding", {0: [1.0]})
+        table.copy_without_time_travel = mock.Mock(return_value=read_table)
+
+        DataEvolutionVectorRead(
+            table,
+            limit=1,
+            vector_column=embedding_field,
+            query_vector=[1.0],
+        ).read_plan(VectorSearchScanPlan(
+            [RawVectorSearchSplit([Range(0, 0)], [], "ivf-flat")],
+            snapshot,
+        ))
+
+        table.copy_without_time_travel.assert_called_once_with({
+            CoreOptions.SCAN_MODE.key(): "from-snapshot",
+            CoreOptions.SCAN_SNAPSHOT_ID.key(): "7",
+            CoreOptions.SCAN_TAG_NAME.key(): None,
+            CoreOptions.SCAN_TIMESTAMP.key(): None,
+        })
+
     def tearDown(self):
         mock.patch.stopall()
 
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorRead.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorRead.java
index 491fa035ef..a65f7dcb82 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorRead.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorRead.java
@@ -71,6 +71,7 @@ public class SparkDataEvolutionVectorRead extends 
DataEvolutionVectorRead {
 
     @Override
     public GlobalIndexResult read(VectorScan.Plan plan) {
+        this.planSnapshot = plan.snapshot();
         List<IndexVectorSearchSplit> indexSplits = new ArrayList<>();
         List<RawVectorSearchSplit> rawSplits = new ArrayList<>();
         splitSearchSplits(plan.splits(), indexSplits, rawSplits);
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
index b89efd1afb..2479118c32 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
@@ -19,6 +19,7 @@
 package org.apache.paimon.spark.read;
 
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
@@ -48,6 +49,7 @@ import org.apache.paimon.table.source.VectorSearchSplit;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.InstantiationUtil;
 import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 import org.apache.paimon.utils.SerializableFunction;
@@ -76,19 +78,63 @@ public class SparkDataEvolutionVectorReadTest {
     @TempDir java.nio.file.Path tempDir;
 
     @Test
-    public void testRawSearchUsesSparkPath() {
+    public void testRawSearchUsesSparkPath() throws Exception {
         TestingSparkVectorRead read = new TestingSparkVectorRead();
+        Snapshot snapshot = snapshot(1L);
         RawVectorSearchSplit rawSplit =
                 new RawVectorSearchSplit(
                         Collections.singletonList(new Range(42, 42)),
                         Collections.emptyList(),
                         null);
-        VectorScan.Plan plan = () -> Collections.singletonList(rawSplit);
+        VectorScan.Plan plan =
+                new VectorScan.Plan() {
+                    @Override
+                    public List<VectorSearchSplit> splits() {
+                        return Collections.singletonList(rawSplit);
+                    }
+
+                    @Override
+                    public Snapshot snapshot() {
+                        return snapshot;
+                    }
+                };
 
         GlobalIndexResult result = read.read(plan);
 
         assertThat(read.rawSparkPathUsed).isTrue();
+        assertThat(read.plannedSnapshot()).isSameAs(snapshot);
         assertThat(result.results().contains(42L)).isTrue();
+
+        byte[] serialized = InstantiationUtil.serializeObject(read);
+        TestingSparkVectorRead restored =
+                InstantiationUtil.deserializeObject(
+                        serialized, 
Thread.currentThread().getContextClassLoader());
+        assertThat(restored.plannedSnapshot().id()).isEqualTo(snapshot.id());
+    }
+
+    private static Snapshot snapshot(long id) {
+        return new Snapshot(
+                id,
+                0L,
+                "base-manifest-list",
+                null,
+                "delta-manifest-list",
+                null,
+                null,
+                null,
+                null,
+                "user",
+                0L,
+                Snapshot.CommitKind.APPEND,
+                0L,
+                0L,
+                0L,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null);
     }
 
     @Test
@@ -177,6 +223,10 @@ public class SparkDataEvolutionVectorReadTest {
                     null);
         }
 
+        private Snapshot plannedSnapshot() {
+            return planSnapshot;
+        }
+
         @Override
         protected GlobalIndexResult readSplits(List<? extends 
VectorSearchSplit> splits) {
             throw new AssertionError("Raw search should not fall back to local 
vector read.");

Reply via email to