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 42b81c3d36 [core] Refactor conflict detection by table type (#9196)
42b81c3d36 is described below

commit 42b81c3d36530c3d258d409e8aabf9f57ba2d387
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Aug 13 10:06:21 2026 +0800

    [core] Refactor conflict detection by table type (#9196)
---
 .../java/org/apache/paimon/AbstractFileStore.java  |   2 +-
 .../paimon/operation/FileStoreCommitImpl.java      | 121 +---
 .../operation/commit/AppendConflictDetection.java  |  71 +++
 .../paimon/operation/commit/ConflictDetection.java | 591 ++++--------------
 .../commit/DataEvolutionConflictDetection.java     | 687 +++++++++++++++++++++
 .../commit/PrimaryKeyConflictDetection.java        | 163 +++++
 .../operation/commit/RowIdConflictChecker.java     |   6 -
 .../paimon/operation/FileStoreCommitTest.java      |   2 +-
 .../operation/commit/ConflictDetectionTest.java    | 607 +++++++++++++++++-
 .../table/DataEvolutionDeletionVectorTest.java     | 107 +++-
 .../paimon/table/DataEvolutionTableTest.java       |  35 ++
 11 files changed, 1768 insertions(+), 624 deletions(-)

diff --git a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java 
b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
index 524dd4962c..1bf2a59e3f 100644
--- a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
@@ -293,7 +293,7 @@ abstract class AbstractFileStore<T> implements FileStore<T> 
{
         }
         ConflictDetection.Factory conflictDetectFactory =
                 scanner ->
-                        new ConflictDetection(
+                        ConflictDetection.create(
                                 tableName,
                                 commitUser,
                                 partitionType,
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 7f9a02f602..eb6ef13cef 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
@@ -27,7 +27,6 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.disk.IOManager;
 import org.apache.paimon.fs.FileIO;
-import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexPathFactory;
 import org.apache.paimon.io.DataFileMeta;
@@ -54,9 +53,7 @@ import 
org.apache.paimon.operation.commit.ManifestEntryChanges;
 import org.apache.paimon.operation.commit.RetryCommitResult;
 import 
org.apache.paimon.operation.commit.RetryCommitResult.CommitFailRetryResult;
 import 
org.apache.paimon.operation.commit.RetryCommitResult.ManifestMergeResult;
-import org.apache.paimon.operation.commit.RowIdColumnConflictChecker;
 import org.apache.paimon.operation.commit.RowIdConflictChecker;
-import org.apache.paimon.operation.commit.RowIdRangeConflictChecker;
 import 
org.apache.paimon.operation.commit.RowTrackingCommitUtils.RowTrackingAssigned;
 import org.apache.paimon.operation.commit.StrictModeChecker;
 import org.apache.paimon.operation.commit.SuccessCommitResult;
@@ -84,7 +81,6 @@ import org.apache.paimon.utils.IndexFilePathFactories;
 import org.apache.paimon.utils.InternalRowPartitionComputer;
 import org.apache.paimon.utils.ListUtils;
 import org.apache.paimon.utils.Pair;
-import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RetryWaiter;
 import org.apache.paimon.utils.SnapshotManager;
 
@@ -99,7 +95,6 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -116,7 +111,6 @@ import static 
org.apache.paimon.manifest.ManifestEntry.nullableRecordCount;
 import static org.apache.paimon.manifest.ManifestEntry.recordCountAdd;
 import static org.apache.paimon.manifest.ManifestEntry.recordCountDelete;
 import static 
org.apache.paimon.operation.commit.ManifestEntryChanges.changedPartitions;
-import static 
org.apache.paimon.operation.commit.RowIdConflictChecker.TriggerSource.MATERIALIZE_DV_COMPACTION;
 import static 
org.apache.paimon.operation.commit.RowTrackingCommitUtils.assignRowTracking;
 import static 
org.apache.paimon.partition.PartitionPredicate.createBinaryPartitions;
 import static 
org.apache.paimon.partition.PartitionPredicate.createPartitionPredicate;
@@ -1060,55 +1054,15 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                     retryResult instanceof CommitFailRetryResult
                             ? (CommitFailRetryResult) retryResult
                             : null;
-            List<Range> changedRowRanges = changedRowRanges(deltaFiles, 
indexFiles, commitKind);
-            // An overwrite may replace the base manifest list without 
recording the replacements
-            // in its delta manifest, so the cached base cannot always be 
refreshed incrementally.
-            if (!changedRowRanges.isEmpty()) {
-                baseDataFiles =
-                        scanner.readAllEntriesFromChangedRowRanges(
-                                latestSnapshot, changedPartitions, 
changedRowRanges);
-                Set<String> dataFilesToDelete =
-                        deltaFiles.stream()
-                                .filter(entry -> entry.kind() == 
FileKind.DELETE)
-                                .map(entry -> entry.file().fileName())
-                                .collect(Collectors.toSet());
-                for (IndexManifestEntry indexFile : indexFiles) {
-                    if (indexFile.indexFile().dvRanges() != null) {
-                        
dataFilesToDelete.addAll(indexFile.indexFile().dvRanges().keySet());
-                    }
-                }
-                if (!dataFilesToDelete.isEmpty()) {
-                    baseDataFiles.addAll(
-                            scanner.readAllEntriesFromDataFiles(
-                                    latestSnapshot, changedPartitions, 
dataFilesToDelete));
-                    baseDataFiles =
-                            new ArrayList<>(
-                                    baseDataFiles.stream()
-                                            .collect(
-                                                    Collectors.toMap(
-                                                            
FileEntry::identifier,
-                                                            entry -> entry,
-                                                            (left, right) -> 
left,
-                                                            
LinkedHashMap::new))
-                                            .values());
-                }
-            } else if (commitFailRetry != null
-                    && commitFailRetry.latestSnapshot != null
-                    && commitFailRetry.baseDataFiles != null
-                    && !hasOverwriteSinceLastAttempt) {
-                baseDataFiles = new ArrayList<>(commitFailRetry.baseDataFiles);
-                List<SimpleFileEntry> incremental =
-                        scanner.readIncrementalChanges(
-                                commitFailRetry.latestSnapshot, 
latestSnapshot, changedPartitions);
-                if (!incremental.isEmpty()) {
-                    baseDataFiles.addAll(incremental);
-                    baseDataFiles = new 
ArrayList<>(FileEntry.mergeEntries(baseDataFiles));
-                }
-            } else {
-                baseDataFiles =
-                        scanner.readAllEntriesFromChangedPartitions(
-                                latestSnapshot, changedPartitions);
-            }
+            baseDataFiles =
+                    conflictDetection.scanBaseDataFiles(
+                            latestSnapshot,
+                            changedPartitions,
+                            deltaFiles,
+                            indexFiles,
+                            commitKind,
+                            commitFailRetry,
+                            hasOverwriteSinceLastAttempt);
             if (discardDuplicate) {
                 Set<FileEntry.Identifier> baseIdentifiers =
                         baseDataFiles.stream()
@@ -1119,36 +1073,9 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                                 .filter(entry -> 
!baseIdentifiers.contains(entry.identifier()))
                                 .collect(Collectors.toList());
             }
-            RowIdConflictChecker rowIdConflictChecker = null;
-            if (conflictDetection.shouldCheckRowIdFromSnapshot(commitKind)) {
-                List<DataFileMeta> rowIdConflictFiles;
-                if (conflictDetection.rowIdConflictCheckTriggerSource()
-                        == MATERIALIZE_DV_COMPACTION) {
-                    // For materialize dv compaction jobs, we should check 
each deleted file range
-                    // will not be erroneously restored by concurrent 
merg-into updates.
-                    rowIdConflictFiles =
-                            deltaFiles.stream()
-                                    .filter(entry -> entry.kind() == 
FileKind.DELETE)
-                                    .map(ManifestEntry::file)
-                                    .filter(file -> file.firstRowId() != null)
-                                    .filter(
-                                            file ->
-                                                    
!isBlobFile(file.fileName())
-                                                            && 
!isVectorStoreFile(file.fileName()))
-                                    .collect(Collectors.toList());
-
-                    rowIdConflictChecker =
-                            
RowIdRangeConflictChecker.fromDataFiles(rowIdConflictFiles);
-                } else {
-                    rowIdConflictFiles =
-                            deltaFiles.stream()
-                                    .map(ManifestEntry::file)
-                                    .collect(Collectors.toList());
-                    rowIdConflictChecker =
-                            RowIdColumnConflictChecker.fromDataFiles(
-                                    schemaManager, rowIdConflictFiles);
-                }
-            }
+            RowIdConflictChecker rowIdConflictChecker =
+                    conflictDetection.createRowIdConflictChecker(
+                            schemaManager, deltaFiles, commitKind);
             Optional<RuntimeException> exception =
                     conflictDetection.checkConflicts(
                             latestSnapshot,
@@ -1381,30 +1308,6 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         return new SuccessCommitResult();
     }
 
-    private List<Range> changedRowRanges(
-            List<ManifestEntry> deltaFiles,
-            List<IndexManifestEntry> indexFiles,
-            CommitKind commitKind) {
-        if (!options.dataEvolutionEnabled() || commitKind != 
CommitKind.COMPACT) {
-            return Collections.emptyList();
-        }
-
-        List<Range> ranges =
-                deltaFiles.stream()
-                        .map(ManifestEntry::file)
-                        .filter(file -> file.firstRowId() != null)
-                        .map(DataFileMeta::nonNullRowIdRange)
-                        .collect(Collectors.toList());
-        indexFiles.stream()
-                .filter(entry -> entry.kind() == FileKind.ADD)
-                .map(IndexManifestEntry::indexFile)
-                .map(IndexFileMeta::globalIndexMeta)
-                .filter(Objects::nonNull)
-                .map(GlobalIndexMeta::rowRange)
-                .forEach(ranges::add);
-        return Range.sortAndMergeOverlap(ranges, true);
-    }
-
     @Nullable
     private ManifestMergeReuse tryReuseManifestMergeResult(
             @Nullable RetryCommitResult retryResult, List<ManifestFileMeta> 
currentManifests) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/AppendConflictDetection.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/AppendConflictDetection.java
new file mode 100644
index 0000000000..f9b1d5df52
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/AppendConflictDetection.java
@@ -0,0 +1,71 @@
+/*
+ * 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.operation.commit;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.Snapshot.CommitKind;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.SimpleFileEntry;
+import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+/** Conflict detection for append-only tables. */
+public class AppendConflictDetection extends ConflictDetection {
+
+    public AppendConflictDetection(
+            String tableName,
+            String commitUser,
+            RowType partitionType,
+            FileStorePathFactory pathFactory,
+            BucketMode bucketMode,
+            boolean deletionVectorsEnabled,
+            IndexFileHandler indexFileHandler,
+            CommitScanner commitScanner) {
+        super(
+                tableName,
+                commitUser,
+                partitionType,
+                pathFactory,
+                bucketMode,
+                deletionVectorsEnabled,
+                indexFileHandler,
+                commitScanner);
+    }
+
+    @Override
+    protected Optional<RuntimeException> checkTableSpecificConflicts(
+            Snapshot latestSnapshot,
+            List<SimpleFileEntry> baseEntries,
+            List<SimpleFileEntry> deltaEntries,
+            List<IndexManifestEntry> deltaIndexEntries,
+            Collection<SimpleFileEntry> mergedEntries,
+            @Nullable RowIdConflictChecker rowIdConflictChecker,
+            CommitKind commitKind,
+            String baseCommitUser) {
+        return Optional.empty();
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
index ca7e57224e..b660d50ad4 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
@@ -22,12 +22,9 @@ import org.apache.paimon.Snapshot;
 import org.apache.paimon.Snapshot.CommitKind;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
-import org.apache.paimon.errors.ErrorMessages;
 import org.apache.paimon.index.DeletionVectorMeta;
-import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileHandler;
 import org.apache.paimon.index.IndexFileMeta;
-import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.FileEntry;
 import org.apache.paimon.manifest.FileKind;
 import org.apache.paimon.manifest.IndexManifestEntry;
@@ -35,13 +32,12 @@ import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.manifest.SimpleFileEntry;
 import org.apache.paimon.manifest.SimpleFileEntryWithDV;
 import org.apache.paimon.operation.PartitionExpire;
+import 
org.apache.paimon.operation.commit.RetryCommitResult.CommitFailRetryResult;
+import org.apache.paimon.schema.SchemaManager;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.FileStorePathFactory;
 import org.apache.paimon.utils.Pair;
-import org.apache.paimon.utils.Range;
-import org.apache.paimon.utils.RangeHelper;
-import org.apache.paimon.utils.RowRangeIndex;
 import org.apache.paimon.utils.SnapshotManager;
 
 import org.slf4j.Logger;
@@ -65,15 +61,11 @@ import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
-import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
-import static 
org.apache.paimon.operation.commit.ManifestEntryChanges.changedPartitions;
-import static 
org.apache.paimon.operation.commit.RowIdConflictChecker.TriggerSource.MATERIALIZE_DV_COMPACTION;
-import static org.apache.paimon.types.VectorType.isVectorStoreFile;
 import static 
org.apache.paimon.utils.InternalRowPartitionComputer.partToSimpleString;
 import static org.apache.paimon.utils.Preconditions.checkState;
 
-/** Util class for detecting conflicts between base and delta files. */
-public class ConflictDetection {
+/** Base class for table-specific conflict detection between base and delta 
files. */
+public abstract class ConflictDetection {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(ConflictDetection.class);
     private static final int FIXED_BUCKET_CHECK_CACHE_MAX_SIZE = 1000;
@@ -82,13 +74,9 @@ public class ConflictDetection {
     private final String commitUser;
     private final RowType partitionType;
     private final FileStorePathFactory pathFactory;
-    private final @Nullable Comparator<InternalRow> keyComparator;
     private final BucketMode bucketMode;
     private final boolean deletionVectorsEnabled;
-    private final boolean dataEvolutionEnabled;
-    private final boolean pkClusteringOverride;
     private final IndexFileHandler indexFileHandler;
-    private final SnapshotManager snapshotManager;
     private final CommitScanner commitScanner;
     private final Set<BinaryRow> checkedFixedBucketPartitions =
             Collections.newSetFromMap(
@@ -101,70 +89,97 @@ public class ConflictDetection {
                     });
 
     private @Nullable PartitionExpire partitionExpire;
-    private @Nullable Long rowIdCheckFromSnapshot = null;
-    private @Nullable RowIdConflictChecker.TriggerSource 
rowIdConflictCheckTriggerSource = null;
 
-    public ConflictDetection(
+    protected ConflictDetection(
             String tableName,
             String commitUser,
             RowType partitionType,
             FileStorePathFactory pathFactory,
-            @Nullable Comparator<InternalRow> keyComparator,
             BucketMode bucketMode,
             boolean deletionVectorsEnabled,
-            boolean dataEvolutionEnabled,
-            boolean pkClusteringOverride,
             IndexFileHandler indexFileHandler,
-            SnapshotManager snapshotManager,
             CommitScanner commitScanner) {
         this.tableName = tableName;
         this.commitUser = commitUser;
         this.partitionType = partitionType;
         this.pathFactory = pathFactory;
-        this.keyComparator = keyComparator;
         this.bucketMode = bucketMode;
         this.deletionVectorsEnabled = deletionVectorsEnabled;
-        this.dataEvolutionEnabled = dataEvolutionEnabled;
-        this.pkClusteringOverride = pkClusteringOverride;
         this.indexFileHandler = indexFileHandler;
-        this.snapshotManager = snapshotManager;
         this.commitScanner = commitScanner;
     }
 
+    public static ConflictDetection create(
+            String tableName,
+            String commitUser,
+            RowType partitionType,
+            FileStorePathFactory pathFactory,
+            @Nullable Comparator<InternalRow> keyComparator,
+            BucketMode bucketMode,
+            boolean deletionVectorsEnabled,
+            boolean dataEvolutionEnabled,
+            boolean pkClusteringOverride,
+            IndexFileHandler indexFileHandler,
+            SnapshotManager snapshotManager,
+            CommitScanner commitScanner) {
+        if (dataEvolutionEnabled) {
+            return new DataEvolutionConflictDetection(
+                    tableName,
+                    commitUser,
+                    partitionType,
+                    pathFactory,
+                    bucketMode,
+                    deletionVectorsEnabled,
+                    indexFileHandler,
+                    snapshotManager,
+                    commitScanner);
+        }
+        if (keyComparator != null) {
+            return new PrimaryKeyConflictDetection(
+                    tableName,
+                    commitUser,
+                    partitionType,
+                    pathFactory,
+                    keyComparator,
+                    bucketMode,
+                    deletionVectorsEnabled,
+                    pkClusteringOverride,
+                    indexFileHandler,
+                    commitScanner);
+        }
+        return new AppendConflictDetection(
+                tableName,
+                commitUser,
+                partitionType,
+                pathFactory,
+                bucketMode,
+                deletionVectorsEnabled,
+                indexFileHandler,
+                commitScanner);
+    }
+
     public void setRowIdCheckFromSnapshot(@Nullable Long 
rowIdCheckFromSnapshot) {
-        setRowIdCheckFromSnapshot(
-                rowIdCheckFromSnapshot, 
RowIdConflictChecker.TriggerSource.DATA_EVOLUTION_DML);
+        // Only Data Evolution tables support Row ID conflict detection.
     }
 
     public void setRowIdCheckFromSnapshotForMaterializeDvCompaction(
             @Nullable Long rowIdCheckFromSnapshot) {
-        setRowIdCheckFromSnapshot(rowIdCheckFromSnapshot, 
MATERIALIZE_DV_COMPACTION);
-    }
-
-    private void setRowIdCheckFromSnapshot(
-            @Nullable Long rowIdCheckFromSnapshot,
-            RowIdConflictChecker.TriggerSource triggerSource) {
-        this.rowIdCheckFromSnapshot = rowIdCheckFromSnapshot;
-        this.rowIdConflictCheckTriggerSource =
-                rowIdCheckFromSnapshot == null ? null : triggerSource;
+        // Only Data Evolution tables support Row ID conflict detection.
     }
 
     public boolean shouldCheckRowIdFromSnapshot(CommitKind commitKind) {
-        return rowIdCheckFromSnapshot != null
-                && (rowIdConflictCheckTriggerSource != 
MATERIALIZE_DV_COMPACTION
-                        || commitKind == CommitKind.COMPACT);
+        return false;
     }
 
-    public RowIdConflictChecker.TriggerSource 
rowIdConflictCheckTriggerSource() {
-        checkState(
-                rowIdConflictCheckTriggerSource != null,
-                "Row ID conflict check trigger source is not set.");
-        return rowIdConflictCheckTriggerSource;
+    @Nullable
+    public RowIdConflictChecker createRowIdConflictChecker(
+            SchemaManager schemaManager, List<ManifestEntry> deltaFiles, 
CommitKind commitKind) {
+        return null;
     }
 
     @Nullable
     public Comparator<InternalRow> keyComparator() {
-        return keyComparator;
+        return null;
     }
 
     public void withPartitionExpire(PartitionExpire partitionExpire) {
@@ -186,7 +201,7 @@ public class ConflictDetection {
         return false;
     }
 
-    public Optional<RuntimeException> checkConflicts(
+    public final Optional<RuntimeException> checkConflicts(
             Snapshot latestSnapshot,
             List<SimpleFileEntry> baseEntries,
             List<SimpleFileEntry> deltaEntries,
@@ -250,29 +265,66 @@ public class ConflictDetection {
         if (exception.isPresent()) {
             return exception;
         }
-        exception = checkKeyRange(baseEntries, deltaEntries, mergedEntries, 
baseCommitUser);
-        if (exception.isPresent()) {
-            return exception;
-        }
+        return checkTableSpecificConflicts(
+                latestSnapshot,
+                baseEntries,
+                deltaEntries,
+                deltaIndexEntries,
+                mergedEntries,
+                rowIdConflictChecker,
+                commitKind,
+                baseCommitUser);
+    }
 
-        Long nextRowId = latestSnapshot.nextRowId();
-        exception = checkRowIdExistence(baseEntries, deltaEntries, nextRowId, 
commitKind);
-        if (exception.isPresent()) {
-            return exception;
-        }
+    protected abstract Optional<RuntimeException> checkTableSpecificConflicts(
+            Snapshot latestSnapshot,
+            List<SimpleFileEntry> baseEntries,
+            List<SimpleFileEntry> deltaEntries,
+            List<IndexManifestEntry> deltaIndexEntries,
+            Collection<SimpleFileEntry> mergedEntries,
+            @Nullable RowIdConflictChecker rowIdConflictChecker,
+            CommitKind commitKind,
+            String baseCommitUser);
 
-        exception = checkRowIdRangeConflicts(commitKind, mergedEntries);
-        if (exception.isPresent()) {
-            return exception;
-        }
+    public List<SimpleFileEntry> scanBaseDataFiles(
+            Snapshot latestSnapshot,
+            List<BinaryRow> changedPartitions,
+            List<ManifestEntry> deltaFiles,
+            List<IndexManifestEntry> indexFiles,
+            CommitKind commitKind,
+            @Nullable CommitFailRetryResult previousAttempt,
+            boolean hasOverwriteSincePreviousAttempt) {
+        return scanChangedPartitions(
+                latestSnapshot,
+                changedPartitions,
+                previousAttempt,
+                hasOverwriteSincePreviousAttempt);
+    }
 
-        exception = checkGlobalIndexRowIdExistence(baseEntries, 
deltaIndexEntries);
-        if (exception.isPresent()) {
-            return exception;
+    protected List<SimpleFileEntry> scanChangedPartitions(
+            Snapshot latestSnapshot,
+            List<BinaryRow> changedPartitions,
+            @Nullable CommitFailRetryResult previousAttempt,
+            boolean hasOverwriteSincePreviousAttempt) {
+        if (previousAttempt != null
+                && previousAttempt.latestSnapshot != null
+                && previousAttempt.baseDataFiles != null
+                && !hasOverwriteSincePreviousAttempt) {
+            List<SimpleFileEntry> baseDataFiles = new 
ArrayList<>(previousAttempt.baseDataFiles);
+            List<SimpleFileEntry> incremental =
+                    commitScanner.readIncrementalChanges(
+                            previousAttempt.latestSnapshot, latestSnapshot, 
changedPartitions);
+            if (!incremental.isEmpty()) {
+                baseDataFiles.addAll(incremental);
+                baseDataFiles = new 
ArrayList<>(FileEntry.mergeEntries(baseDataFiles));
+            }
+            return baseDataFiles;
         }
+        return 
commitScanner.readAllEntriesFromChangedPartitions(latestSnapshot, 
changedPartitions);
+    }
 
-        return checkForRowIdFromSnapshot(
-                latestSnapshot, deltaEntries, deltaIndexEntries, 
rowIdConflictChecker);
+    protected CommitScanner commitScanner() {
+        return commitScanner;
     }
 
     public <T extends FileEntry> Map<BinaryRow, Integer> 
collectUncheckedFixedBucketPartitions(
@@ -378,59 +430,6 @@ public class ConflictDetection {
                         partInfo, numBuckets, previousNumBuckets));
     }
 
-    private Optional<RuntimeException> checkKeyRange(
-            List<SimpleFileEntry> baseEntries,
-            List<SimpleFileEntry> deltaEntries,
-            Collection<SimpleFileEntry> mergedEntries,
-            String baseCommitUser) {
-        // fast exit for file store without keys
-        if (keyComparator == null) {
-            return Optional.empty();
-        }
-
-        // Not clustering by pk, no need to check key range
-        if (pkClusteringOverride) {
-            return Optional.empty();
-        }
-
-        // group entries by partitions, buckets and levels
-        Map<LevelIdentifier, List<SimpleFileEntry>> levels = new HashMap<>();
-        for (SimpleFileEntry entry : mergedEntries) {
-            int level = entry.level();
-            if (level >= 1) {
-                levels.computeIfAbsent(
-                                new LevelIdentifier(entry.partition(), 
entry.bucket(), level),
-                                lv -> new ArrayList<>())
-                        .add(entry);
-            }
-        }
-
-        // check for all LSM level >= 1, key ranges of files do not intersect
-        for (List<SimpleFileEntry> entries : levels.values()) {
-            entries.sort((a, b) -> keyComparator.compare(a.minKey(), 
b.minKey()));
-            for (int i = 0; i + 1 < entries.size(); i++) {
-                SimpleFileEntry a = entries.get(i);
-                SimpleFileEntry b = entries.get(i + 1);
-                if (keyComparator.compare(a.maxKey(), b.minKey()) >= 0) {
-                    Pair<RuntimeException, RuntimeException> conflictException 
=
-                            createConflictException(
-                                    "LSM conflicts detected! Give up 
committing. Conflict files are:\n"
-                                            + 
a.identifier().toString(pathFactory)
-                                            + "\n"
-                                            + 
b.identifier().toString(pathFactory),
-                                    baseCommitUser,
-                                    baseEntries,
-                                    deltaEntries,
-                                    null);
-
-                    LOG.warn("", conflictException.getLeft());
-                    return Optional.of(conflictException.getRight());
-                }
-            }
-        }
-        return Optional.empty();
-    }
-
     private Function<Throwable, RuntimeException> conflictException(
             String baseCommitUser,
             List<SimpleFileEntry> baseEntries,
@@ -497,329 +496,6 @@ public class ConflictDetection {
         return Optional.empty();
     }
 
-    private Optional<RuntimeException> checkRowIdRangeConflicts(
-            CommitKind commitKind, Collection<SimpleFileEntry> mergedEntries) {
-        if (!dataEvolutionEnabled) {
-            return Optional.empty();
-        }
-        if (rowIdCheckFromSnapshot == null && commitKind != 
CommitKind.COMPACT) {
-            return Optional.empty();
-        }
-
-        List<SimpleFileEntry> entries =
-                mergedEntries.stream()
-                        .filter(file -> file.firstRowId() != null)
-                        .collect(Collectors.toList());
-
-        RangeHelper<SimpleFileEntry> rangeHelper =
-                new RangeHelper<>(SimpleFileEntry::nonNullRowIdRange);
-        List<SimpleFileEntry> dataFiles =
-                entries.stream()
-                        .filter(file -> !dedicatedStorageFile(file.fileName()))
-                        .collect(Collectors.toList());
-
-        Optional<RuntimeException> exception =
-                checkDataFileRowIdRangeConflicts(rangeHelper, dataFiles);
-        if (exception.isPresent()) {
-            return exception;
-        }
-
-        List<SimpleFileEntry> dedicatedFiles =
-                entries.stream()
-                        .filter(file -> dedicatedStorageFile(file.fileName()))
-                        .collect(Collectors.toList());
-        exception = checkDedicatedFileRowIdRangeConflicts(dataFiles, 
dedicatedFiles);
-        if (exception.isPresent()) {
-            return exception;
-        }
-
-        return Optional.empty();
-    }
-
-    private Optional<RuntimeException> checkDataFileRowIdRangeConflicts(
-            RangeHelper<SimpleFileEntry> rangeHelper, List<SimpleFileEntry> 
dataFiles) {
-        for (List<SimpleFileEntry> dataFileGroup : 
rangeHelper.mergeOverlappingRanges(dataFiles)) {
-            if (!rangeHelper.areAllRangesSame(dataFileGroup)) {
-                return Optional.of(
-                        new RuntimeException(
-                                "For Data Evolution table, multiple 'MERGE 
INTO' and 'COMPACT' "
-                                        + "operations "
-                                        + "have encountered conflicts, data 
files: "
-                                        + dataFileGroup));
-            }
-        }
-
-        return Optional.empty();
-    }
-
-    private Optional<RuntimeException> checkDedicatedFileRowIdRangeConflicts(
-            List<SimpleFileEntry> dataFiles, List<SimpleFileEntry> 
dedicatedFiles) {
-        if (dedicatedFiles.isEmpty()) {
-            return Optional.empty();
-        }
-
-        RowRangeIndex dataFileRowRangeIndex = rowRangeIndex(dataFiles, false);
-
-        for (SimpleFileEntry dedicatedFile : dedicatedFiles) {
-            Range dedicatedRange = dedicatedFile.nonNullRowIdRange();
-            if (dataFileRowRangeIndex.contains(dedicatedRange)) {
-                continue;
-            }
-
-            List<Range> intersectingRanges =
-                    
dataFileRowRangeIndex.intersectedRanges(dedicatedRange.from, dedicatedRange.to);
-            List<SimpleFileEntry> intersectingDataFiles =
-                    dataFiles.stream()
-                            .filter(
-                                    dataFile ->
-                                            dataFile.nonNullRowIdRange()
-                                                    
.hasIntersection(dedicatedRange))
-                            .collect(Collectors.toList());
-            String conflictReason =
-                    intersectingRanges.size() > 1
-                            ? "spans multiple data file ranges"
-                            : "is not covered by one data file range";
-            return Optional.of(
-                    new RuntimeException(
-                            String.format(
-                                    "For Data Evolution table, multiple 'MERGE 
INTO' and 'COMPACT' "
-                                            + "operations have encountered 
conflicts, dedicated "
-                                            + "file %s %s %s: %s",
-                                    dedicatedFile,
-                                    dedicatedRange,
-                                    conflictReason,
-                                    intersectingDataFiles)));
-        }
-
-        return Optional.empty();
-    }
-
-    private static RowRangeIndex rowRangeIndex(
-            Collection<SimpleFileEntry> files, boolean mergeAdjacent) {
-        return RowRangeIndex.create(
-                
files.stream().map(SimpleFileEntry::nonNullRowIdRange).collect(Collectors.toList()),
-                mergeAdjacent);
-    }
-
-    private Optional<RuntimeException> checkForRowIdFromSnapshot(
-            Snapshot latestSnapshot,
-            List<SimpleFileEntry> deltaEntries,
-            List<IndexManifestEntry> deltaIndexEntries,
-            @Nullable RowIdConflictChecker conflictChecker) {
-        if (!dataEvolutionEnabled) {
-            return Optional.empty();
-        }
-        if (rowIdCheckFromSnapshot == null) {
-            return Optional.empty();
-        }
-        if (conflictChecker == null || conflictChecker.isEmpty()) {
-            return Optional.empty();
-        }
-
-        List<BinaryRow> changedPartitions = changedPartitions(deltaEntries, 
deltaIndexEntries);
-
-        // check history row id ranges
-        Long checkNextRowId = 
snapshotManager.snapshot(rowIdCheckFromSnapshot).nextRowId();
-        checkState(
-                checkNextRowId != null,
-                "Next row id cannot be null for snapshot %s.",
-                rowIdCheckFromSnapshot);
-        for (long i = rowIdCheckFromSnapshot + 1; i <= latestSnapshot.id(); 
i++) {
-            Snapshot snapshot = snapshotManager.snapshot(i);
-            if (snapshot.commitKind() == CommitKind.COMPACT) {
-                continue;
-            }
-            List<ManifestEntry> changes =
-                    commitScanner.readIncrementalEntries(snapshot, 
changedPartitions);
-            for (ManifestEntry entry : changes) {
-                if (!shouldCheckHistoricalRowIdEntry(entry.kind())) {
-                    continue;
-                }
-                DataFileMeta file = entry.file();
-                if (file.firstRowId() != null
-                        && file.nonNullRowIdRange().from < checkNextRowId
-                        && conflictChecker.conflictsWith(file)) {
-                    LOG.debug(
-                            "Data evolution row id conflict detected for table 
{}, commit user {}, "
-                                    + "snapshot {}, file {}.",
-                            tableName,
-                            commitUser,
-                            snapshot.id(),
-                            file);
-                    return Optional.of(
-                            new RuntimeException(
-                                    
ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE));
-                }
-            }
-        }
-
-        return Optional.empty();
-    }
-
-    boolean shouldCheckHistoricalRowIdEntry(FileKind kind) {
-        return rowIdConflictCheckTriggerSource != MATERIALIZE_DV_COMPACTION || 
kind == FileKind.ADD;
-    }
-
-    private Optional<RuntimeException> checkGlobalIndexRowIdExistence(
-            List<SimpleFileEntry> baseEntries, List<IndexManifestEntry> 
deltaIndexEntries) {
-        if (!dataEvolutionEnabled) {
-            return Optional.empty();
-        }
-
-        List<IndexManifestEntry> indexesToCheck = 
globalIndexFileAdditions(deltaIndexEntries);
-        if (indexesToCheck.isEmpty()) {
-            return Optional.empty();
-        }
-
-        Map<Pair<BinaryRow, Integer>, List<Range>> dataRanges = new 
HashMap<>();
-        for (SimpleFileEntry entry : baseEntries) {
-            if (entry.kind() == FileKind.ADD && entry.firstRowId() != null) {
-                dataRanges
-                        .computeIfAbsent(
-                                Pair.of(entry.partition(), entry.bucket()), k 
-> new ArrayList<>())
-                        .add(entry.nonNullRowIdRange());
-            }
-        }
-        Map<Pair<BinaryRow, Integer>, RowRangeIndex> rowRangeIndexes =
-                dataRanges.entrySet().stream()
-                        .collect(
-                                Collectors.toMap(
-                                        Map.Entry::getKey,
-                                        entry -> 
RowRangeIndex.create(entry.getValue())));
-
-        for (IndexManifestEntry indexEntry : indexesToCheck) {
-            GlobalIndexMeta globalIndex = 
indexEntry.indexFile().globalIndexMeta();
-            checkState(globalIndex != null, "Global index meta must not be 
null.");
-            Range indexRange = globalIndex.rowRange();
-            RowRangeIndex rowRangeIndex =
-                    rowRangeIndexes.get(Pair.of(indexEntry.partition(), 
indexEntry.bucket()));
-            if (rowRangeIndex == null || !rowRangeIndex.contains(indexRange)) {
-                return Optional.of(
-                        new RuntimeException(
-                                String.format(
-                                        "Global index row ID existence 
conflict: index file '%s' "
-                                                + "references row range %s, 
but this range "
-                                                + "is not fully covered by 
current data "
-                                                + "files. The referenced row 
IDs may have been "
-                                                + "reassigned or removed by a 
concurrent commit.",
-                                        indexEntry.indexFile().fileName(), 
indexRange)));
-            }
-        }
-        return Optional.empty();
-    }
-
-    private List<IndexManifestEntry> globalIndexFileAdditions(
-            List<IndexManifestEntry> indexFileChanges) {
-        List<IndexManifestEntry> result = new ArrayList<>();
-        for (IndexManifestEntry entry : indexFileChanges) {
-            if (entry.kind() == FileKind.ADD && 
entry.indexFile().globalIndexMeta() != null) {
-                result.add(entry);
-            }
-        }
-        return result;
-    }
-
-    Optional<RuntimeException> checkRowIdExistence(
-            List<SimpleFileEntry> baseEntries,
-            List<SimpleFileEntry> deltaEntries,
-            @Nullable Long nextRowId,
-            CommitKind commitKind) {
-        if (!dataEvolutionEnabled) {
-            return Optional.empty();
-        }
-
-        List<SimpleFileEntry> existingDataFiles =
-                baseEntries.stream()
-                        .filter(
-                                base ->
-                                        base.firstRowId() != null
-                                                && 
!dedicatedStorageFile(base.fileName()))
-                        .collect(Collectors.toList());
-
-        if (commitKind == CommitKind.COMPACT) {
-            return checkCompactRowIdExistence(existingDataFiles, deltaEntries);
-        }
-        return checkNonCompactRowIdExistence(existingDataFiles, deltaEntries, 
nextRowId);
-    }
-
-    /**
-     * Checks conflicts between compaction and concurrent Row ID reassignment, 
which may otherwise
-     * cause reassigned Row IDs to fall back. For example, compaction produces 
a file with Row IDs
-     * [0, 9], then reassignment moves the current range to [10, 19]. 
Committing the stale
-     * compaction would move the Row IDs back to [0, 9].
-     */
-    private Optional<RuntimeException> checkCompactRowIdExistence(
-            List<SimpleFileEntry> existingDataFiles, List<SimpleFileEntry> 
deltaEntries) {
-        // A compaction output may cover multiple adjacent normal files, but 
only within the same
-        // partition and bucket.
-        Map<Pair<BinaryRow, Integer>, List<SimpleFileEntry>> dataFilesByBucket 
=
-                existingDataFiles.stream()
-                        .collect(
-                                Collectors.groupingBy(
-                                        file -> Pair.of(file.partition(), 
file.bucket())));
-        Map<Pair<BinaryRow, Integer>, RowRangeIndex> existingIndexes =
-                dataFilesByBucket.entrySet().stream()
-                        .collect(
-                                Collectors.toMap(
-                                        Map.Entry::getKey,
-                                        entry -> 
rowRangeIndex(entry.getValue(), true)));
-
-        for (SimpleFileEntry entry : deltaEntries) {
-            if (entry.kind() != FileKind.ADD || entry.firstRowId() == null) {
-                continue;
-            }
-
-            RowRangeIndex existingIndex =
-                    existingIndexes.get(Pair.of(entry.partition(), 
entry.bucket()));
-            if (existingIndex == null || 
!existingIndex.contains(entry.nonNullRowIdRange())) {
-                return Optional.of(rowIdExistenceConflict(entry));
-            }
-        }
-        return Optional.empty();
-    }
-
-    private Optional<RuntimeException> checkNonCompactRowIdExistence(
-            List<SimpleFileEntry> existingDataFiles,
-            List<SimpleFileEntry> deltaEntries,
-            @Nullable Long nextRowId) {
-        List<SimpleFileEntry> filesToCheck =
-                deltaEntries.stream()
-                        .filter(
-                                e ->
-                                        e.kind() == FileKind.ADD
-                                                && e.firstRowId() != null
-                                                && nextRowId != null
-                                                && e.firstRowId() < nextRowId)
-                        .collect(Collectors.toList());
-
-        if (filesToCheck.isEmpty()) {
-            return Optional.empty();
-        }
-
-        RowRangeIndex existingIndex = rowRangeIndex(existingDataFiles, false);
-
-        for (SimpleFileEntry entry : filesToCheck) {
-            Range rowRange = entry.nonNullRowIdRange();
-            boolean exists =
-                    dedicatedStorageFile(entry.fileName())
-                            ? existingIndex.contains(rowRange)
-                            : existingIndex.containsExactly(rowRange);
-            if (!exists) {
-                return Optional.of(rowIdExistenceConflict(entry));
-            }
-        }
-        return Optional.empty();
-    }
-
-    private RowIdExistenceConflictException 
rowIdExistenceConflict(SimpleFileEntry entry) {
-        return new RowIdExistenceConflictException(
-                entry.fileName(), entry.firstRowId(), entry.rowCount(), 
entry.bucket());
-    }
-
-    private static boolean dedicatedStorageFile(String fileName) {
-        return isBlobFile(fileName) || isVectorStoreFile(fileName);
-    }
-
     static List<SimpleFileEntry> buildBaseEntriesWithDV(
             List<SimpleFileEntry> baseEntries, List<IndexManifestEntry> 
baseIndexEntries) {
         if (baseEntries.isEmpty()) {
@@ -937,7 +613,7 @@ public class ConflictDetection {
      * simplified exception), The simplified exception is generated when the 
entry length is larger
      * than the max limit.
      */
-    private Pair<RuntimeException, RuntimeException> createConflictException(
+    protected final Pair<RuntimeException, RuntimeException> 
createConflictException(
             String message,
             String baseCommitUser,
             List<SimpleFileEntry> baseEntries,
@@ -1020,35 +696,6 @@ public class ConflictDetection {
         }
     }
 
-    private static class LevelIdentifier {
-
-        private final BinaryRow partition;
-        private final int bucket;
-        private final int level;
-
-        private LevelIdentifier(BinaryRow partition, int bucket, int level) {
-            this.partition = partition;
-            this.bucket = bucket;
-            this.level = level;
-        }
-
-        @Override
-        public boolean equals(Object o) {
-            if (!(o instanceof LevelIdentifier)) {
-                return false;
-            }
-            LevelIdentifier that = (LevelIdentifier) o;
-            return Objects.equals(partition, that.partition)
-                    && bucket == that.bucket
-                    && level == that.level;
-        }
-
-        @Override
-        public int hashCode() {
-            return Objects.hash(partition, bucket, level);
-        }
-    }
-
     /** Factory to create {@link ConflictDetection}. */
     public interface Factory {
         ConflictDetection create(CommitScanner scanner);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
new file mode 100644
index 0000000000..c04e3d4651
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
@@ -0,0 +1,687 @@
+/*
+ * 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.operation.commit;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.Snapshot.CommitKind;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.errors.ErrorMessages;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileEntry;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.SimpleFileEntry;
+import 
org.apache.paimon.operation.commit.RetryCommitResult.CommitFailRetryResult;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RangeHelper;
+import org.apache.paimon.utils.RowRangeIndex;
+import org.apache.paimon.utils.SnapshotManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
+import static 
org.apache.paimon.operation.commit.ManifestEntryChanges.changedPartitions;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.Preconditions.checkState;
+
+/** Conflict detection for Data Evolution tables. */
+public class DataEvolutionConflictDetection extends ConflictDetection {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(DataEvolutionConflictDetection.class);
+
+    private final String tableName;
+    private final String commitUser;
+    private final SnapshotManager snapshotManager;
+
+    private @Nullable Long rowIdCheckFromSnapshot;
+    private @Nullable RowIdConflictCheckStrategy rowIdConflictCheckStrategy;
+
+    public DataEvolutionConflictDetection(
+            String tableName,
+            String commitUser,
+            RowType partitionType,
+            FileStorePathFactory pathFactory,
+            BucketMode bucketMode,
+            boolean deletionVectorsEnabled,
+            IndexFileHandler indexFileHandler,
+            SnapshotManager snapshotManager,
+            CommitScanner commitScanner) {
+        super(
+                tableName,
+                commitUser,
+                partitionType,
+                pathFactory,
+                bucketMode,
+                deletionVectorsEnabled,
+                indexFileHandler,
+                commitScanner);
+        this.tableName = tableName;
+        this.commitUser = commitUser;
+        this.snapshotManager = snapshotManager;
+    }
+
+    @Override
+    public void setRowIdCheckFromSnapshot(@Nullable Long 
rowIdCheckFromSnapshot) {
+        setRowIdCheckFromSnapshot(
+                rowIdCheckFromSnapshot, 
DataEvolutionDmlRowIdConflictCheck.INSTANCE);
+    }
+
+    @Override
+    public void setRowIdCheckFromSnapshotForMaterializeDvCompaction(
+            @Nullable Long rowIdCheckFromSnapshot) {
+        setRowIdCheckFromSnapshot(rowIdCheckFromSnapshot, 
MaterializeDvRowIdConflictCheck.INSTANCE);
+    }
+
+    private void setRowIdCheckFromSnapshot(
+            @Nullable Long rowIdCheckFromSnapshot,
+            RowIdConflictCheckStrategy conflictCheckStrategy) {
+        this.rowIdCheckFromSnapshot = rowIdCheckFromSnapshot;
+        this.rowIdConflictCheckStrategy =
+                rowIdCheckFromSnapshot == null ? null : conflictCheckStrategy;
+    }
+
+    @Override
+    public boolean shouldCheckRowIdFromSnapshot(CommitKind commitKind) {
+        return rowIdCheckFromSnapshot != null && 
rowIdConflictCheckStrategy().appliesTo(commitKind);
+    }
+
+    @Override
+    @Nullable
+    public RowIdConflictChecker createRowIdConflictChecker(
+            SchemaManager schemaManager, List<ManifestEntry> deltaFiles, 
CommitKind commitKind) {
+        if (!shouldCheckRowIdFromSnapshot(commitKind)) {
+            return null;
+        }
+        return rowIdConflictCheckStrategy().createChecker(schemaManager, 
deltaFiles);
+    }
+
+    private RowIdConflictCheckStrategy rowIdConflictCheckStrategy() {
+        checkState(
+                rowIdConflictCheckStrategy != null, "Row ID conflict check 
strategy is not set.");
+        return rowIdConflictCheckStrategy;
+    }
+
+    @Override
+    public List<SimpleFileEntry> scanBaseDataFiles(
+            Snapshot latestSnapshot,
+            List<BinaryRow> changedPartitions,
+            List<ManifestEntry> deltaFiles,
+            List<IndexManifestEntry> indexFiles,
+            CommitKind commitKind,
+            @Nullable CommitFailRetryResult previousAttempt,
+            boolean hasOverwriteSincePreviousAttempt) {
+        if (commitKind == CommitKind.COMPACT) {
+            return scanCompactBaseDataFiles(
+                    latestSnapshot,
+                    changedPartitions,
+                    deltaFiles,
+                    indexFiles,
+                    previousAttempt,
+                    hasOverwriteSincePreviousAttempt);
+        }
+        if (commitKind == CommitKind.OVERWRITE) {
+            return scanOverwriteBaseDataFiles(
+                    latestSnapshot,
+                    changedPartitions,
+                    deltaFiles,
+                    indexFiles,
+                    previousAttempt,
+                    hasOverwriteSincePreviousAttempt);
+        }
+        return super.scanBaseDataFiles(
+                latestSnapshot,
+                changedPartitions,
+                deltaFiles,
+                indexFiles,
+                commitKind,
+                previousAttempt,
+                hasOverwriteSincePreviousAttempt);
+    }
+
+    private List<SimpleFileEntry> scanCompactBaseDataFiles(
+            Snapshot latestSnapshot,
+            List<BinaryRow> changedPartitions,
+            List<ManifestEntry> deltaFiles,
+            List<IndexManifestEntry> indexFiles,
+            @Nullable CommitFailRetryResult previousAttempt,
+            boolean hasOverwriteSincePreviousAttempt) {
+        List<Range> changedRowRanges = changedRowRanges(deltaFiles, 
indexFiles);
+        if (changedRowRanges.isEmpty()) {
+            return super.scanBaseDataFiles(
+                    latestSnapshot,
+                    changedPartitions,
+                    deltaFiles,
+                    indexFiles,
+                    CommitKind.COMPACT,
+                    previousAttempt,
+                    hasOverwriteSincePreviousAttempt);
+        }
+        return scanChangedRowRanges(
+                latestSnapshot,
+                changedPartitions,
+                changedRowRanges,
+                referencedDataFiles(deltaFiles, indexFiles));
+    }
+
+    private List<SimpleFileEntry> scanOverwriteBaseDataFiles(
+            Snapshot latestSnapshot,
+            List<BinaryRow> changedPartitions,
+            List<ManifestEntry> deltaFiles,
+            List<IndexManifestEntry> indexFiles,
+            @Nullable CommitFailRetryResult previousAttempt,
+            boolean hasOverwriteSincePreviousAttempt) {
+        List<Range> changedRowRanges = changedRowRanges(deltaFiles, 
indexFiles);
+        Set<String> referencedDataFiles = referencedDataFiles(deltaFiles, 
indexFiles);
+        if (!changedRowRanges.isEmpty()) {
+            return scanChangedRowRanges(
+                    latestSnapshot, changedPartitions, changedRowRanges, 
referencedDataFiles);
+        }
+        if (!referencedDataFiles.isEmpty()) {
+            return commitScanner()
+                    .readAllEntriesFromDataFiles(
+                            latestSnapshot, changedPartitions, 
referencedDataFiles);
+        }
+        return super.scanBaseDataFiles(
+                latestSnapshot,
+                changedPartitions,
+                deltaFiles,
+                indexFiles,
+                CommitKind.OVERWRITE,
+                previousAttempt,
+                hasOverwriteSincePreviousAttempt);
+    }
+
+    private List<SimpleFileEntry> scanChangedRowRanges(
+            Snapshot latestSnapshot,
+            List<BinaryRow> changedPartitions,
+            List<Range> changedRowRanges,
+            Set<String> referencedDataFiles) {
+        List<SimpleFileEntry> baseDataFiles =
+                new ArrayList<>(
+                        commitScanner()
+                                .readAllEntriesFromChangedRowRanges(
+                                        latestSnapshot, changedPartitions, 
changedRowRanges));
+        referencedDataFiles.removeAll(
+                
baseDataFiles.stream().map(SimpleFileEntry::fileName).collect(Collectors.toSet()));
+        if (referencedDataFiles.isEmpty()) {
+            return baseDataFiles;
+        }
+
+        baseDataFiles.addAll(
+                commitScanner()
+                        .readAllEntriesFromDataFiles(
+                                latestSnapshot, changedPartitions, 
referencedDataFiles));
+        return new ArrayList<>(
+                baseDataFiles.stream()
+                        .collect(
+                                Collectors.toMap(
+                                        FileEntry::identifier,
+                                        entry -> entry,
+                                        (left, right) -> left,
+                                        LinkedHashMap::new))
+                        .values());
+    }
+
+    private Set<String> referencedDataFiles(
+            List<ManifestEntry> deltaFiles, List<IndexManifestEntry> 
indexFiles) {
+        Set<String> referencedDataFiles =
+                deltaFiles.stream()
+                        .filter(entry -> entry.kind() == FileKind.DELETE)
+                        .map(entry -> entry.file().fileName())
+                        .collect(Collectors.toSet());
+        for (IndexManifestEntry indexFile : indexFiles) {
+            if (indexFile.indexFile().dvRanges() != null) {
+                
referencedDataFiles.addAll(indexFile.indexFile().dvRanges().keySet());
+            }
+        }
+        return referencedDataFiles;
+    }
+
+    private List<Range> changedRowRanges(
+            List<ManifestEntry> deltaFiles, List<IndexManifestEntry> 
indexFiles) {
+        List<Range> ranges =
+                deltaFiles.stream()
+                        .map(ManifestEntry::file)
+                        .filter(file -> file.firstRowId() != null)
+                        .map(DataFileMeta::nonNullRowIdRange)
+                        .collect(Collectors.toList());
+        indexFiles.stream()
+                .filter(entry -> entry.kind() == FileKind.ADD)
+                .map(IndexManifestEntry::indexFile)
+                .map(IndexFileMeta::globalIndexMeta)
+                .filter(Objects::nonNull)
+                .map(GlobalIndexMeta::rowRange)
+                .forEach(ranges::add);
+        return Range.sortAndMergeOverlap(ranges, true);
+    }
+
+    @Override
+    protected Optional<RuntimeException> checkTableSpecificConflicts(
+            Snapshot latestSnapshot,
+            List<SimpleFileEntry> baseEntries,
+            List<SimpleFileEntry> deltaEntries,
+            List<IndexManifestEntry> deltaIndexEntries,
+            Collection<SimpleFileEntry> mergedEntries,
+            @Nullable RowIdConflictChecker rowIdConflictChecker,
+            CommitKind commitKind,
+            String baseCommitUser) {
+        Optional<RuntimeException> exception =
+                checkRowIdExistence(
+                        baseEntries, deltaEntries, latestSnapshot.nextRowId(), 
commitKind);
+        if (exception.isPresent()) {
+            return exception;
+        }
+
+        exception = checkRowIdRangeConflicts(commitKind, mergedEntries);
+        if (exception.isPresent()) {
+            return exception;
+        }
+
+        exception = checkGlobalIndexRowIdExistence(baseEntries, 
deltaIndexEntries);
+        if (exception.isPresent()) {
+            return exception;
+        }
+
+        return checkForRowIdFromSnapshot(
+                latestSnapshot, deltaEntries, deltaIndexEntries, 
rowIdConflictChecker);
+    }
+
+    private Optional<RuntimeException> checkRowIdRangeConflicts(
+            CommitKind commitKind, Collection<SimpleFileEntry> mergedEntries) {
+        if (rowIdCheckFromSnapshot == null && commitKind != 
CommitKind.COMPACT) {
+            return Optional.empty();
+        }
+
+        List<SimpleFileEntry> entries =
+                mergedEntries.stream()
+                        .filter(file -> file.firstRowId() != null)
+                        .collect(Collectors.toList());
+
+        RangeHelper<SimpleFileEntry> rangeHelper =
+                new RangeHelper<>(SimpleFileEntry::nonNullRowIdRange);
+        List<SimpleFileEntry> dataFiles =
+                entries.stream()
+                        .filter(file -> !dedicatedStorageFile(file.fileName()))
+                        .collect(Collectors.toList());
+
+        Optional<RuntimeException> exception =
+                checkDataFileRowIdRangeConflicts(rangeHelper, dataFiles);
+        if (exception.isPresent()) {
+            return exception;
+        }
+
+        List<SimpleFileEntry> dedicatedFiles =
+                entries.stream()
+                        .filter(file -> dedicatedStorageFile(file.fileName()))
+                        .collect(Collectors.toList());
+        return checkDedicatedFileRowIdRangeConflicts(dataFiles, 
dedicatedFiles);
+    }
+
+    private Optional<RuntimeException> checkDataFileRowIdRangeConflicts(
+            RangeHelper<SimpleFileEntry> rangeHelper, List<SimpleFileEntry> 
dataFiles) {
+        for (List<SimpleFileEntry> dataFileGroup : 
rangeHelper.mergeOverlappingRanges(dataFiles)) {
+            if (!rangeHelper.areAllRangesSame(dataFileGroup)) {
+                return Optional.of(
+                        new RuntimeException(
+                                "For Data Evolution table, multiple 'MERGE 
INTO' and 'COMPACT' "
+                                        + "operations "
+                                        + "have encountered conflicts, data 
files: "
+                                        + dataFileGroup));
+            }
+        }
+        return Optional.empty();
+    }
+
+    private Optional<RuntimeException> checkDedicatedFileRowIdRangeConflicts(
+            List<SimpleFileEntry> dataFiles, List<SimpleFileEntry> 
dedicatedFiles) {
+        if (dedicatedFiles.isEmpty()) {
+            return Optional.empty();
+        }
+
+        RowRangeIndex dataFileRowRangeIndex = rowRangeIndex(dataFiles, false);
+        for (SimpleFileEntry dedicatedFile : dedicatedFiles) {
+            Range dedicatedRange = dedicatedFile.nonNullRowIdRange();
+            if (dataFileRowRangeIndex.contains(dedicatedRange)) {
+                continue;
+            }
+
+            List<Range> intersectingRanges =
+                    
dataFileRowRangeIndex.intersectedRanges(dedicatedRange.from, dedicatedRange.to);
+            List<SimpleFileEntry> intersectingDataFiles =
+                    dataFiles.stream()
+                            .filter(
+                                    dataFile ->
+                                            dataFile.nonNullRowIdRange()
+                                                    
.hasIntersection(dedicatedRange))
+                            .collect(Collectors.toList());
+            String conflictReason =
+                    intersectingRanges.size() > 1
+                            ? "spans multiple data file ranges"
+                            : "is not covered by one data file range";
+            return Optional.of(
+                    new RuntimeException(
+                            String.format(
+                                    "For Data Evolution table, multiple 'MERGE 
INTO' and 'COMPACT' "
+                                            + "operations have encountered 
conflicts, dedicated "
+                                            + "file %s %s %s: %s",
+                                    dedicatedFile,
+                                    dedicatedRange,
+                                    conflictReason,
+                                    intersectingDataFiles)));
+        }
+        return Optional.empty();
+    }
+
+    private Optional<RuntimeException> checkForRowIdFromSnapshot(
+            Snapshot latestSnapshot,
+            List<SimpleFileEntry> deltaEntries,
+            List<IndexManifestEntry> deltaIndexEntries,
+            @Nullable RowIdConflictChecker conflictChecker) {
+        if (rowIdCheckFromSnapshot == null
+                || conflictChecker == null
+                || conflictChecker.isEmpty()) {
+            return Optional.empty();
+        }
+
+        List<BinaryRow> changedPartitions = changedPartitions(deltaEntries, 
deltaIndexEntries);
+        Long checkNextRowId = 
snapshotManager.snapshot(rowIdCheckFromSnapshot).nextRowId();
+        checkState(
+                checkNextRowId != null,
+                "Next row id cannot be null for snapshot %s.",
+                rowIdCheckFromSnapshot);
+        for (long i = rowIdCheckFromSnapshot + 1; i <= latestSnapshot.id(); 
i++) {
+            Snapshot snapshot = snapshotManager.snapshot(i);
+            if (snapshot.commitKind() == CommitKind.COMPACT) {
+                continue;
+            }
+            List<ManifestEntry> changes =
+                    commitScanner().readIncrementalEntries(snapshot, 
changedPartitions);
+            for (ManifestEntry entry : changes) {
+                if (!shouldCheckHistoricalRowIdEntry(entry.kind())) {
+                    continue;
+                }
+                DataFileMeta file = entry.file();
+                if (file.firstRowId() != null
+                        && file.nonNullRowIdRange().from < checkNextRowId
+                        && conflictChecker.conflictsWith(file)) {
+                    LOG.debug(
+                            "Data evolution row id conflict detected for table 
{}, commit user {}, "
+                                    + "snapshot {}, file {}.",
+                            tableName,
+                            commitUser,
+                            snapshot.id(),
+                            file);
+                    return Optional.of(
+                            new RuntimeException(
+                                    
ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE));
+                }
+            }
+        }
+        return Optional.empty();
+    }
+
+    boolean shouldCheckHistoricalRowIdEntry(FileKind kind) {
+        return rowIdConflictCheckStrategy().shouldCheckHistoricalEntry(kind);
+    }
+
+    private interface RowIdConflictCheckStrategy {
+
+        boolean appliesTo(CommitKind commitKind);
+
+        RowIdConflictChecker createChecker(
+                SchemaManager schemaManager, List<ManifestEntry> deltaFiles);
+
+        boolean shouldCheckHistoricalEntry(FileKind kind);
+    }
+
+    private static class DataEvolutionDmlRowIdConflictCheck implements 
RowIdConflictCheckStrategy {
+
+        private static final DataEvolutionDmlRowIdConflictCheck INSTANCE =
+                new DataEvolutionDmlRowIdConflictCheck();
+
+        @Override
+        public boolean appliesTo(CommitKind commitKind) {
+            return true;
+        }
+
+        @Override
+        public RowIdConflictChecker createChecker(
+                SchemaManager schemaManager, List<ManifestEntry> deltaFiles) {
+            return RowIdColumnConflictChecker.fromDataFiles(
+                    schemaManager,
+                    
deltaFiles.stream().map(ManifestEntry::file).collect(Collectors.toList()));
+        }
+
+        @Override
+        public boolean shouldCheckHistoricalEntry(FileKind kind) {
+            return true;
+        }
+    }
+
+    private static class MaterializeDvRowIdConflictCheck implements 
RowIdConflictCheckStrategy {
+
+        private static final MaterializeDvRowIdConflictCheck INSTANCE =
+                new MaterializeDvRowIdConflictCheck();
+
+        @Override
+        public boolean appliesTo(CommitKind commitKind) {
+            return commitKind == CommitKind.COMPACT;
+        }
+
+        @Override
+        public RowIdConflictChecker createChecker(
+                SchemaManager schemaManager, List<ManifestEntry> deltaFiles) {
+            // Materializing deletion vectors rewrites complete row ranges. A 
concurrent ADD in a
+            // deleted normal-file range can otherwise restore logically 
deleted rows.
+            List<DataFileMeta> deletedNormalFiles =
+                    deltaFiles.stream()
+                            .filter(entry -> entry.kind() == FileKind.DELETE)
+                            .map(ManifestEntry::file)
+                            .filter(file -> file.firstRowId() != null)
+                            .filter(
+                                    file ->
+                                            !isBlobFile(file.fileName())
+                                                    && 
!isVectorStoreFile(file.fileName()))
+                            .collect(Collectors.toList());
+            return RowIdRangeConflictChecker.fromDataFiles(deletedNormalFiles);
+        }
+
+        @Override
+        public boolean shouldCheckHistoricalEntry(FileKind kind) {
+            return kind == FileKind.ADD;
+        }
+    }
+
+    private Optional<RuntimeException> checkGlobalIndexRowIdExistence(
+            List<SimpleFileEntry> baseEntries, List<IndexManifestEntry> 
deltaIndexEntries) {
+        List<IndexManifestEntry> indexesToCheck = 
globalIndexFileAdditions(deltaIndexEntries);
+        if (indexesToCheck.isEmpty()) {
+            return Optional.empty();
+        }
+
+        Map<Pair<BinaryRow, Integer>, List<Range>> dataRanges = new 
HashMap<>();
+        for (SimpleFileEntry entry : baseEntries) {
+            if (entry.kind() == FileKind.ADD && entry.firstRowId() != null) {
+                dataRanges
+                        .computeIfAbsent(
+                                Pair.of(entry.partition(), entry.bucket()), k 
-> new ArrayList<>())
+                        .add(entry.nonNullRowIdRange());
+            }
+        }
+        Map<Pair<BinaryRow, Integer>, RowRangeIndex> rowRangeIndexes =
+                dataRanges.entrySet().stream()
+                        .collect(
+                                Collectors.toMap(
+                                        Map.Entry::getKey,
+                                        entry -> 
RowRangeIndex.create(entry.getValue())));
+
+        for (IndexManifestEntry indexEntry : indexesToCheck) {
+            GlobalIndexMeta globalIndex = 
indexEntry.indexFile().globalIndexMeta();
+            checkState(globalIndex != null, "Global index meta must not be 
null.");
+            Range indexRange = globalIndex.rowRange();
+            RowRangeIndex rowRangeIndex =
+                    rowRangeIndexes.get(Pair.of(indexEntry.partition(), 
indexEntry.bucket()));
+            if (rowRangeIndex == null || !rowRangeIndex.contains(indexRange)) {
+                return Optional.of(
+                        new RuntimeException(
+                                String.format(
+                                        "Global index row ID existence 
conflict: index file '%s' "
+                                                + "references row range %s, 
but this range "
+                                                + "is not fully covered by 
current data "
+                                                + "files. The referenced row 
IDs may have been "
+                                                + "reassigned or removed by a 
concurrent commit.",
+                                        indexEntry.indexFile().fileName(), 
indexRange)));
+            }
+        }
+        return Optional.empty();
+    }
+
+    private List<IndexManifestEntry> globalIndexFileAdditions(
+            List<IndexManifestEntry> indexFileChanges) {
+        List<IndexManifestEntry> result = new ArrayList<>();
+        for (IndexManifestEntry entry : indexFileChanges) {
+            if (entry.kind() == FileKind.ADD && 
entry.indexFile().globalIndexMeta() != null) {
+                result.add(entry);
+            }
+        }
+        return result;
+    }
+
+    Optional<RuntimeException> checkRowIdExistence(
+            List<SimpleFileEntry> baseEntries,
+            List<SimpleFileEntry> deltaEntries,
+            @Nullable Long nextRowId,
+            CommitKind commitKind) {
+        List<SimpleFileEntry> existingDataFiles =
+                baseEntries.stream()
+                        .filter(
+                                base ->
+                                        base.firstRowId() != null
+                                                && 
!dedicatedStorageFile(base.fileName()))
+                        .collect(Collectors.toList());
+
+        if (commitKind == CommitKind.COMPACT) {
+            return checkCompactRowIdExistence(existingDataFiles, deltaEntries);
+        }
+        return checkNonCompactRowIdExistence(existingDataFiles, deltaEntries, 
nextRowId);
+    }
+
+    /**
+     * Checks conflicts between compaction and concurrent Row ID reassignment, 
which may otherwise
+     * cause reassigned Row IDs to fall back. For example, compaction produces 
a file with Row IDs
+     * [0, 9], then reassignment moves the current range to [10, 19]. 
Committing the stale
+     * compaction would move the Row IDs back to [0, 9].
+     */
+    private Optional<RuntimeException> checkCompactRowIdExistence(
+            List<SimpleFileEntry> existingDataFiles, List<SimpleFileEntry> 
deltaEntries) {
+        Map<Pair<BinaryRow, Integer>, List<SimpleFileEntry>> dataFilesByBucket 
=
+                existingDataFiles.stream()
+                        .collect(
+                                Collectors.groupingBy(
+                                        file -> Pair.of(file.partition(), 
file.bucket())));
+        Map<Pair<BinaryRow, Integer>, RowRangeIndex> existingIndexes =
+                dataFilesByBucket.entrySet().stream()
+                        .collect(
+                                Collectors.toMap(
+                                        Map.Entry::getKey,
+                                        entry -> 
rowRangeIndex(entry.getValue(), true)));
+
+        for (SimpleFileEntry entry : deltaEntries) {
+            if (entry.kind() != FileKind.ADD || entry.firstRowId() == null) {
+                continue;
+            }
+            RowRangeIndex existingIndex =
+                    existingIndexes.get(Pair.of(entry.partition(), 
entry.bucket()));
+            if (existingIndex == null || 
!existingIndex.contains(entry.nonNullRowIdRange())) {
+                return Optional.of(rowIdExistenceConflict(entry));
+            }
+        }
+        return Optional.empty();
+    }
+
+    private Optional<RuntimeException> checkNonCompactRowIdExistence(
+            List<SimpleFileEntry> existingDataFiles,
+            List<SimpleFileEntry> deltaEntries,
+            @Nullable Long nextRowId) {
+        List<SimpleFileEntry> filesToCheck =
+                deltaEntries.stream()
+                        .filter(
+                                entry ->
+                                        entry.kind() == FileKind.ADD
+                                                && entry.firstRowId() != null
+                                                && nextRowId != null
+                                                && entry.firstRowId() < 
nextRowId)
+                        .collect(Collectors.toList());
+        if (filesToCheck.isEmpty()) {
+            return Optional.empty();
+        }
+
+        RowRangeIndex existingIndex = rowRangeIndex(existingDataFiles, false);
+        for (SimpleFileEntry entry : filesToCheck) {
+            Range rowRange = entry.nonNullRowIdRange();
+            boolean exists =
+                    dedicatedStorageFile(entry.fileName())
+                            ? existingIndex.contains(rowRange)
+                            : existingIndex.containsExactly(rowRange);
+            if (!exists) {
+                return Optional.of(rowIdExistenceConflict(entry));
+            }
+        }
+        return Optional.empty();
+    }
+
+    private RowIdExistenceConflictException 
rowIdExistenceConflict(SimpleFileEntry entry) {
+        return new RowIdExistenceConflictException(
+                entry.fileName(), entry.firstRowId(), entry.rowCount(), 
entry.bucket());
+    }
+
+    private static RowRangeIndex rowRangeIndex(
+            Collection<SimpleFileEntry> files, boolean mergeAdjacent) {
+        return RowRangeIndex.create(
+                
files.stream().map(SimpleFileEntry::nonNullRowIdRange).collect(Collectors.toList()),
+                mergeAdjacent);
+    }
+
+    private static boolean dedicatedStorageFile(String fileName) {
+        return isBlobFile(fileName) || isVectorStoreFile(fileName);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/PrimaryKeyConflictDetection.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/PrimaryKeyConflictDetection.java
new file mode 100644
index 0000000000..2e8b4d34e3
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/PrimaryKeyConflictDetection.java
@@ -0,0 +1,163 @@
+/*
+ * 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.operation.commit;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.Snapshot.CommitKind;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.SimpleFileEntry;
+import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Pair;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/** Conflict detection for primary-key tables. */
+public class PrimaryKeyConflictDetection extends ConflictDetection {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(PrimaryKeyConflictDetection.class);
+
+    private final FileStorePathFactory pathFactory;
+    private final Comparator<InternalRow> keyComparator;
+    private final boolean pkClusteringOverride;
+
+    public PrimaryKeyConflictDetection(
+            String tableName,
+            String commitUser,
+            RowType partitionType,
+            FileStorePathFactory pathFactory,
+            Comparator<InternalRow> keyComparator,
+            BucketMode bucketMode,
+            boolean deletionVectorsEnabled,
+            boolean pkClusteringOverride,
+            IndexFileHandler indexFileHandler,
+            CommitScanner commitScanner) {
+        super(
+                tableName,
+                commitUser,
+                partitionType,
+                pathFactory,
+                bucketMode,
+                deletionVectorsEnabled,
+                indexFileHandler,
+                commitScanner);
+        this.pathFactory = pathFactory;
+        this.keyComparator = keyComparator;
+        this.pkClusteringOverride = pkClusteringOverride;
+    }
+
+    @Override
+    public Comparator<InternalRow> keyComparator() {
+        return keyComparator;
+    }
+
+    @Override
+    protected Optional<RuntimeException> checkTableSpecificConflicts(
+            Snapshot latestSnapshot,
+            List<SimpleFileEntry> baseEntries,
+            List<SimpleFileEntry> deltaEntries,
+            List<IndexManifestEntry> deltaIndexEntries,
+            Collection<SimpleFileEntry> mergedEntries,
+            @Nullable RowIdConflictChecker rowIdConflictChecker,
+            CommitKind commitKind,
+            String baseCommitUser) {
+        if (pkClusteringOverride) {
+            return Optional.empty();
+        }
+
+        Map<LevelIdentifier, List<SimpleFileEntry>> levels = new HashMap<>();
+        for (SimpleFileEntry entry : mergedEntries) {
+            int level = entry.level();
+            if (level >= 1) {
+                levels.computeIfAbsent(
+                                new LevelIdentifier(entry.partition(), 
entry.bucket(), level),
+                                lv -> new ArrayList<>())
+                        .add(entry);
+            }
+        }
+
+        for (List<SimpleFileEntry> entries : levels.values()) {
+            entries.sort((a, b) -> keyComparator.compare(a.minKey(), 
b.minKey()));
+            for (int i = 0; i + 1 < entries.size(); i++) {
+                SimpleFileEntry a = entries.get(i);
+                SimpleFileEntry b = entries.get(i + 1);
+                if (keyComparator.compare(a.maxKey(), b.minKey()) >= 0) {
+                    Pair<RuntimeException, RuntimeException> conflictException 
=
+                            createConflictException(
+                                    "LSM conflicts detected! Give up 
committing. Conflict files are:\n"
+                                            + 
a.identifier().toString(pathFactory)
+                                            + "\n"
+                                            + 
b.identifier().toString(pathFactory),
+                                    baseCommitUser,
+                                    baseEntries,
+                                    deltaEntries,
+                                    null);
+                    LOG.warn("", conflictException.getLeft());
+                    return Optional.of(conflictException.getRight());
+                }
+            }
+        }
+        return Optional.empty();
+    }
+
+    private static class LevelIdentifier {
+
+        private final BinaryRow partition;
+        private final int bucket;
+        private final int level;
+
+        private LevelIdentifier(BinaryRow partition, int bucket, int level) {
+            this.partition = partition;
+            this.bucket = bucket;
+            this.level = level;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (!(o instanceof LevelIdentifier)) {
+                return false;
+            }
+            LevelIdentifier that = (LevelIdentifier) o;
+            return Objects.equals(partition, that.partition)
+                    && bucket == that.bucket
+                    && level == that.level;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(partition, bucket, level);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdConflictChecker.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdConflictChecker.java
index e5e9808946..fb3614bfec 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdConflictChecker.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdConflictChecker.java
@@ -26,10 +26,4 @@ public interface RowIdConflictChecker {
     boolean isEmpty();
 
     boolean conflictsWith(DataFileMeta file);
-
-    /** Defines the source operation which triggers this conflict check. */
-    enum TriggerSource {
-        DATA_EVOLUTION_DML,
-        MATERIALIZE_DV_COMPACTION
-    }
 }
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 f326be18dc..d427345fa7 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
@@ -1998,7 +1998,7 @@ public class FileStoreCommitTest {
                 Collections.emptyList(),
                 Collections.emptyList(),
                 scanner ->
-                        new ConflictDetection(
+                        ConflictDetection.create(
                                 tableName,
                                 commitUser,
                                 store.partitionType(),
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
index 71757cb49d..e200faaa0e 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
@@ -23,13 +23,19 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.index.DeletionVectorMeta;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.FileEntry;
 import org.apache.paimon.manifest.FileKind;
 import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.manifest.SimpleFileEntry;
 import org.apache.paimon.manifest.SimpleFileEntryWithDV;
+import 
org.apache.paimon.operation.commit.RetryCommitResult.CommitFailRetryResult;
+import org.apache.paimon.schema.SchemaManager;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.SnapshotManager;
 
 import org.junit.jupiter.api.Test;
 
@@ -39,9 +45,11 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 
 import static org.apache.paimon.data.BinaryRow.EMPTY_ROW;
 import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
@@ -50,9 +58,376 @@ import static org.apache.paimon.manifest.FileKind.DELETE;
 import static 
org.apache.paimon.operation.commit.ConflictDetection.buildBaseEntriesWithDV;
 import static 
org.apache.paimon.operation.commit.ConflictDetection.buildDeltaEntriesWithDV;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 class ConflictDetectionTest {
 
+    @Test
+    void testCreateConflictDetectionByTableType() {
+        ConflictDetection append = createConflictDetection(null, false, false);
+        assertThat(append).isInstanceOf(AppendConflictDetection.class);
+        assertThat(append.keyComparator()).isNull();
+        assertThat(createConflictDetection(null, false, true))
+                .isInstanceOf(PrimaryKeyConflictDetection.class);
+        assertThat(createConflictDetection(null, true, false))
+                .isInstanceOf(DataEvolutionConflictDetection.class);
+        // Data Evolution takes precedence even if a key comparator is 
supplied.
+        assertThat(createConflictDetection(null, true, true))
+                .isInstanceOf(DataEvolutionConflictDetection.class);
+    }
+
+    @Test
+    void testPrimaryKeyClusteringOverrideSkipsLsmConflictCheck() {
+        ConflictDetection detection = createConflictDetection(null, false, 
true, true, null);
+
+        assertThat(
+                        detection.checkConflicts(
+                                snapshot(1),
+                                
Collections.singletonList(createLevelFileEntry("base", 1)),
+                                
Collections.singletonList(createLevelFileEntry("delta", 1)),
+                                Collections.emptyList(),
+                                null,
+                                Snapshot.CommitKind.COMPACT))
+                .isEmpty();
+    }
+
+    @Test
+    void testDataEvolutionCompactScansChangedRowRanges() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot snapshot = snapshot(1);
+        BinaryRow partition = BinaryRow.singleColumn(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(partition);
+        Range changedRange = new Range(10, 19);
+
+        ManifestEntry delta = mock(ManifestEntry.class);
+        DataFileMeta dataFile = mock(DataFileMeta.class);
+        when(delta.kind()).thenReturn(ADD);
+        when(delta.file()).thenReturn(dataFile);
+        when(dataFile.firstRowId()).thenReturn(10L);
+        when(dataFile.nonNullRowIdRange()).thenReturn(changedRange);
+        when(scanner.readAllEntriesFromChangedRowRanges(
+                        snapshot, changedPartitions, 
Collections.singletonList(changedRange)))
+                .thenReturn(Collections.emptyList());
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Collections.singletonList(delta),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.COMPACT,
+                                null,
+                                false))
+                .isEmpty();
+        verify(scanner)
+                .readAllEntriesFromChangedRowRanges(
+                        snapshot, changedPartitions, 
Collections.singletonList(changedRange));
+    }
+
+    @Test
+    void testDataEvolutionCompactSupplementsReferencedDataFiles() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot snapshot = snapshot(1);
+        BinaryRow partition = BinaryRow.singleColumn(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(partition);
+        Range changedRange = new Range(10, 19);
+
+        ManifestEntry added = manifestEntry(ADD, "added", 10L, changedRange);
+        ManifestEntry deleted = manifestEntry(DELETE, "legacy", null, null);
+        SimpleFileEntry rangeBase =
+                createFileEntryWithRowId("range-base", ADD, partition, 0, 10L, 
10L);
+        SimpleFileEntry legacyBase = createFileEntry("legacy", ADD);
+        SimpleFileEntry dvBase = createFileEntry("dv-base", ADD);
+        Set<String> referencedFiles = new HashSet<>(Arrays.asList("legacy", 
"dv-base"));
+        when(scanner.readAllEntriesFromChangedRowRanges(
+                        snapshot, changedPartitions, 
Collections.singletonList(changedRange)))
+                .thenReturn(Collections.singletonList(rangeBase));
+        when(scanner.readAllEntriesFromDataFiles(snapshot, changedPartitions, 
referencedFiles))
+                .thenReturn(Arrays.asList(legacyBase, dvBase));
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Arrays.asList(added, deleted),
+                                Collections.singletonList(
+                                        createDvIndexEntry(
+                                                "dv", ADD, 
Collections.singletonList("dv-base"))),
+                                Snapshot.CommitKind.COMPACT,
+                                null,
+                                false))
+                .containsExactly(rangeBase, legacyBase, dvBase);
+        verify(scanner).readAllEntriesFromDataFiles(snapshot, 
changedPartitions, referencedFiles);
+    }
+
+    @Test
+    void testDataEvolutionCompactWithoutRowRangesReusesRetryScan() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot previousSnapshot = snapshot(1);
+        Snapshot latestSnapshot = snapshot(2);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(BinaryRow.singleColumn(1));
+        SimpleFileEntry oldBase = createFileEntry("old", ADD);
+        SimpleFileEntry removedBase = createFileEntry("old", DELETE);
+        SimpleFileEntry newBase = createFileEntry("new", ADD);
+        List<SimpleFileEntry> cachedBase = Collections.singletonList(oldBase);
+        CommitFailRetryResult previousAttempt = 
commitFailRetryResult(previousSnapshot, cachedBase);
+        when(scanner.readIncrementalChanges(previousSnapshot, latestSnapshot, 
changedPartitions))
+                .thenReturn(Arrays.asList(removedBase, newBase));
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                latestSnapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.COMPACT,
+                                previousAttempt,
+                                false))
+                .containsExactly(newBase);
+        assertThat(cachedBase).containsExactly(oldBase);
+        verify(scanner, never())
+                .readAllEntriesFromChangedPartitions(latestSnapshot, 
changedPartitions);
+    }
+
+    @Test
+    void testRetryFallsBackWhenCachedScanCannotBeReused() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        ConflictDetection detection = createConflictDetection(scanner, false, 
false);
+        Snapshot previousSnapshot = snapshot(1);
+        Snapshot latestSnapshot = snapshot(2);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(BinaryRow.singleColumn(1));
+        List<SimpleFileEntry> cachedBase =
+                Collections.singletonList(createFileEntry("cached", ADD));
+        List<SimpleFileEntry> expected = 
Collections.singletonList(createFileEntry("latest", ADD));
+        when(scanner.readAllEntriesFromChangedPartitions(latestSnapshot, 
changedPartitions))
+                .thenReturn(expected);
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                latestSnapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.APPEND,
+                                commitFailRetryResult(null, cachedBase),
+                                false))
+                .isSameAs(expected);
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                latestSnapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.APPEND,
+                                commitFailRetryResult(previousSnapshot, null),
+                                false))
+                .isSameAs(expected);
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                latestSnapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.APPEND,
+                                commitFailRetryResult(previousSnapshot, 
cachedBase),
+                                true))
+                .isSameAs(expected);
+        verify(scanner, times(3))
+                .readAllEntriesFromChangedPartitions(latestSnapshot, 
changedPartitions);
+        verify(scanner, never())
+                .readIncrementalChanges(previousSnapshot, latestSnapshot, 
changedPartitions);
+    }
+
+    @Test
+    void testDataEvolutionOverwriteScansDeletedFileRowRange() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot snapshot = snapshot(1);
+        BinaryRow partition = BinaryRow.singleColumn(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(partition);
+        Range changedRange = new Range(10, 19);
+
+        ManifestEntry deleted = manifestEntry(DELETE, "deleted", 10L, 
changedRange);
+        SimpleFileEntry base = createFileEntryWithRowId("deleted", ADD, 
partition, 0, 10L, 10L);
+        when(scanner.readAllEntriesFromChangedRowRanges(
+                        snapshot, changedPartitions, 
Collections.singletonList(changedRange)))
+                .thenReturn(Collections.singletonList(base));
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Collections.singletonList(deleted),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.OVERWRITE,
+                                null,
+                                false))
+                .containsExactly(base);
+        verify(scanner, never())
+                .readAllEntriesFromDataFiles(
+                        snapshot, changedPartitions, 
Collections.singleton("deleted"));
+        verify(scanner, never()).readAllEntriesFromChangedPartitions(snapshot, 
changedPartitions);
+    }
+
+    @Test
+    void testDataEvolutionOverwriteScansDeletionVectorDataFile() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot snapshot = snapshot(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(BinaryRow.singleColumn(1));
+        SimpleFileEntry base = createFileEntry("base", ADD);
+        when(scanner.readAllEntriesFromDataFiles(
+                        snapshot, changedPartitions, 
Collections.singleton("base")))
+                .thenReturn(Collections.singletonList(base));
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.singletonList(
+                                        createDvIndexEntry(
+                                                "dv", ADD, 
Collections.singletonList("base"))),
+                                Snapshot.CommitKind.OVERWRITE,
+                                null,
+                                false))
+                .containsExactly(base);
+        verify(scanner, never()).readAllEntriesFromChangedPartitions(snapshot, 
changedPartitions);
+    }
+
+    @Test
+    void testDataEvolutionOverwriteSupplementsLegacyDeletedFile() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot snapshot = snapshot(1);
+        BinaryRow partition = BinaryRow.singleColumn(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(partition);
+        Range changedRange = new Range(10, 19);
+
+        ManifestEntry added = manifestEntry(ADD, "added", 10L, changedRange);
+        ManifestEntry deleted = manifestEntry(DELETE, "legacy", null, null);
+        SimpleFileEntry rangeBase =
+                createFileEntryWithRowId("range-base", ADD, partition, 0, 10L, 
10L);
+        SimpleFileEntry legacyBase = createFileEntry("legacy", ADD);
+        when(scanner.readAllEntriesFromChangedRowRanges(
+                        snapshot, changedPartitions, 
Collections.singletonList(changedRange)))
+                .thenReturn(Collections.singletonList(rangeBase));
+        when(scanner.readAllEntriesFromDataFiles(
+                        snapshot, changedPartitions, 
Collections.singleton("legacy")))
+                .thenReturn(Collections.singletonList(legacyBase));
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Arrays.asList(added, deleted),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.OVERWRITE,
+                                null,
+                                false))
+                .containsExactly(rangeBase, legacyBase);
+        verify(scanner, never()).readAllEntriesFromChangedPartitions(snapshot, 
changedPartitions);
+    }
+
+    @Test
+    void testDataEvolutionOverwriteFallsBackWithoutSelectors() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot snapshot = snapshot(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(BinaryRow.singleColumn(1));
+        List<SimpleFileEntry> expected = 
Collections.singletonList(createFileEntry("base", ADD));
+        when(scanner.readAllEntriesFromChangedPartitions(snapshot, 
changedPartitions))
+                .thenReturn(expected);
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.OVERWRITE,
+                                null,
+                                false))
+                .isSameAs(expected);
+    }
+
+    @Test
+    void testDataEvolutionAppendUsesDefaultPartitionScan() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection) 
createConflictDetection(scanner, true, false);
+        Snapshot snapshot = snapshot(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(BinaryRow.singleColumn(1));
+        List<SimpleFileEntry> expected = 
Collections.singletonList(createFileEntry("base", ADD));
+        when(scanner.readAllEntriesFromChangedPartitions(snapshot, 
changedPartitions))
+                .thenReturn(expected);
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.APPEND,
+                                null,
+                                false))
+                .isSameAs(expected);
+    }
+
+    @Test
+    void testAppendScansChangedPartitions() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        ConflictDetection detection = createConflictDetection(scanner, false, 
false);
+        Snapshot snapshot = snapshot(1);
+        List<BinaryRow> changedPartitions = 
Collections.singletonList(BinaryRow.singleColumn(1));
+        List<SimpleFileEntry> expected = 
Collections.singletonList(createFileEntry("base", ADD));
+        when(scanner.readAllEntriesFromChangedPartitions(snapshot, 
changedPartitions))
+                .thenReturn(expected);
+
+        assertThat(
+                        detection.scanBaseDataFiles(
+                                snapshot,
+                                changedPartitions,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Snapshot.CommitKind.APPEND,
+                                null,
+                                false))
+                .isSameAs(expected);
+        verify(scanner).readAllEntriesFromChangedPartitions(snapshot, 
changedPartitions);
+    }
+
+    @Test
+    void testAppendSkipsDataEvolutionConflictChecks() {
+        ConflictDetection detection = createConflictDetection(null, false, 
false);
+
+        Optional<RuntimeException> exception =
+                detection.checkConflicts(
+                        snapshot(1),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.singletonList(
+                                createGlobalIndexEntry("idx", ADD, 
BinaryRow.EMPTY_ROW, 0, 149)),
+                        null,
+                        Snapshot.CommitKind.APPEND);
+
+        assertThat(exception).isEmpty();
+    }
+
     @Test
     public void testBuildBaseEntriesWithDV() {
         {
@@ -314,6 +689,23 @@ class ConflictDetectionTest {
                 null);
     }
 
+    private SimpleFileEntry createLevelFileEntry(String fileName, int level) {
+        return new SimpleFileEntry(
+                ADD,
+                EMPTY_ROW,
+                0,
+                1,
+                level,
+                fileName,
+                Collections.emptyList(),
+                null,
+                EMPTY_ROW,
+                EMPTY_ROW,
+                null,
+                0L,
+                null);
+    }
+
     private SimpleFileEntryWithDV createFileEntryWithDV(
             String fileName, FileKind kind, @Nullable String dvFileName) {
         return new SimpleFileEntryWithDV(createFileEntry(fileName, kind), 
dvFileName);
@@ -369,7 +761,7 @@ class ConflictDetectionTest {
 
     @Test
     void testShouldBeOverwriteCommit() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> addOnlyEntries = new ArrayList<>();
         addOnlyEntries.add(createFileEntry("f1", ADD));
@@ -400,7 +792,7 @@ class ConflictDetectionTest {
 
     @Test
     void testMaterializeDvRowIdCheckOnlyAppliesToCompactCommit() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         detection.setRowIdCheckFromSnapshotForMaterializeDvCompaction(1L);
         
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.APPEND)).isFalse();
@@ -409,17 +801,98 @@ class ConflictDetectionTest {
         
assertThat(detection.shouldCheckHistoricalRowIdEntry(FileKind.ADD)).isTrue();
         
assertThat(detection.shouldCheckHistoricalRowIdEntry(FileKind.DELETE)).isFalse();
 
+        RowIdConflictChecker checker =
+                detection.createRowIdConflictChecker(
+                        mock(SchemaManager.class),
+                        Arrays.asList(
+                                manifestEntry(DELETE, "deleted", 10L, new 
Range(10, 19)),
+                                manifestEntry(ADD, "added", 30L, new Range(30, 
39)),
+                                manifestEntry(DELETE, "dedicated.blob", 50L, 
new Range(50, 59)),
+                                manifestEntry(
+                                        DELETE, "dedicated.vector.data", 70L, 
new Range(70, 79))),
+                        Snapshot.CommitKind.COMPACT);
+        assertThat(checker).isNotNull();
+        assertThat(
+                        checker.conflictsWith(
+                                manifestEntry(ADD, "historical", 15L, new 
Range(15, 24)).file()))
+                .isTrue();
+        assertThat(
+                        checker.conflictsWith(
+                                manifestEntry(ADD, "historical", 35L, new 
Range(35, 44)).file()))
+                .isFalse();
+        assertThat(
+                        checker.conflictsWith(
+                                manifestEntry(ADD, "historical", 55L, new 
Range(55, 64)).file()))
+                .isFalse();
+        assertThat(
+                        checker.conflictsWith(
+                                manifestEntry(ADD, "historical", 75L, new 
Range(75, 84)).file()))
+                .isFalse();
+
         detection.setRowIdCheckFromSnapshot(1L);
         
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.APPEND)).isTrue();
         
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.COMPACT)).isTrue();
         
assertThat(detection.shouldCheckHistoricalRowIdEntry(FileKind.ADD)).isTrue();
         
assertThat(detection.shouldCheckHistoricalRowIdEntry(FileKind.DELETE)).isTrue();
+        assertThat(
+                        detection.createRowIdConflictChecker(
+                                mock(SchemaManager.class),
+                                Collections.singletonList(manifestEntry(ADD, 
"added", null, null)),
+                                Snapshot.CommitKind.APPEND))
+                .isInstanceOf(RowIdColumnConflictChecker.class);
 
         detection.setRowIdCheckFromSnapshot(null);
         
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.APPEND)).isFalse();
         
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.COMPACT)).isFalse();
     }
 
+    @Test
+    void testMaterializeRowIdCheckSkipsCompactSnapshotsAndHistoricalDeletes() {
+        CommitScanner scanner = mock(CommitScanner.class);
+        SnapshotManager snapshotManager = mock(SnapshotManager.class);
+        DataEvolutionConflictDetection detection =
+                (DataEvolutionConflictDetection)
+                        createConflictDetection(scanner, true, false, false, 
snapshotManager);
+        detection.setRowIdCheckFromSnapshotForMaterializeDvCompaction(1L);
+
+        Snapshot checkSnapshot = mock(Snapshot.class);
+        Snapshot compactSnapshot = mock(Snapshot.class);
+        Snapshot appendSnapshot = mock(Snapshot.class);
+        Snapshot latestSnapshot = mock(Snapshot.class);
+        when(checkSnapshot.nextRowId()).thenReturn(20L);
+        
when(compactSnapshot.commitKind()).thenReturn(Snapshot.CommitKind.COMPACT);
+        
when(appendSnapshot.commitKind()).thenReturn(Snapshot.CommitKind.APPEND);
+        when(latestSnapshot.id()).thenReturn(3L);
+        when(latestSnapshot.commitUser()).thenReturn("other-user");
+        when(snapshotManager.snapshot(1)).thenReturn(checkSnapshot);
+        when(snapshotManager.snapshot(2)).thenReturn(compactSnapshot);
+        when(snapshotManager.snapshot(3)).thenReturn(appendSnapshot);
+
+        ManifestEntry historicalDelete =
+                manifestEntry(DELETE, "historical", 10L, new Range(10, 19));
+        ManifestEntry historicalAdd = manifestEntry(ADD, "non-conflicting", 
10L, new Range(10, 19));
+        when(scanner.readIncrementalEntries(appendSnapshot, 
Collections.emptyList()))
+                .thenReturn(Arrays.asList(historicalDelete, historicalAdd));
+        RowIdConflictChecker checker = mock(RowIdConflictChecker.class);
+        when(checker.isEmpty()).thenReturn(false);
+        when(checker.conflictsWith(historicalDelete.file())).thenReturn(true);
+        when(checker.conflictsWith(historicalAdd.file())).thenReturn(false);
+
+        assertThat(
+                        detection.checkConflicts(
+                                latestSnapshot,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                checker,
+                                Snapshot.CommitKind.COMPACT))
+                .isEmpty();
+        verify(scanner, never()).readIncrementalEntries(compactSnapshot, 
Collections.emptyList());
+        verify(scanner).readIncrementalEntries(appendSnapshot, 
Collections.emptyList());
+        verify(checker, never()).conflictsWith(historicalDelete.file());
+        verify(checker).conflictsWith(historicalAdd.file());
+    }
+
     @Test
     void testChangedPartitionsIncludesGlobalIndexFiles() {
         BinaryRow partition = BinaryRow.singleColumn(1);
@@ -434,7 +907,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceNoConflict() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 100L));
@@ -450,7 +923,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceBaseFileRemoved() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
 
@@ -468,7 +941,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceBaseFileRewritten() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         baseEntries.add(createFileEntryWithRowId("f2", ADD, 0L, 200L));
@@ -487,7 +960,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceNormalFileRejectsAdjacentDataFiles() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 2L));
@@ -505,7 +978,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceDedicatedFileCoveredByDataFiles() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 4L));
@@ -521,7 +994,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceDedicatedFileRejectsAdjacentDataFiles() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 2L));
@@ -539,7 +1012,7 @@ class ConflictDetectionTest {
 
     @Test
     void 
testCheckRowIdExistenceDedicatedFileRejectsRangeNotCoveredByOneDataFile() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 2L));
@@ -556,7 +1029,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceDedicatedFileIgnoresBaseDedicatedFiles() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         baseEntries.add(createFileEntryWithRowId("old.blob", ADD, 0L, 2L));
@@ -573,7 +1046,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceSkipsNewlyAppendedFiles() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         // nextRowId=100: files with firstRowId >= 100 are newly appended, not 
references
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
@@ -593,7 +1066,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceSkipsNonPreAssigned() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
 
@@ -608,7 +1081,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceSkipsDeleteEntries() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
 
@@ -623,7 +1096,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceSkipsWhenNextRowIdNull() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries = new ArrayList<>();
         List<SimpleFileEntry> deltaEntries = new ArrayList<>();
@@ -637,7 +1110,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceCompactAllowsAdjacentNormalRanges() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries =
                 Arrays.asList(
@@ -654,7 +1127,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceCompactAllowsBlobAcrossAdjacentNormalRanges() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries =
                 Arrays.asList(
@@ -671,7 +1144,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdExistenceCompactRejectsStaleRangeAfterReassign() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         List<SimpleFileEntry> baseEntries =
                 Arrays.asList(
@@ -681,15 +1154,20 @@ class ConflictDetectionTest {
                 
Collections.singletonList(createFileEntryWithRowId("compacted", ADD, 0L, 4L));
 
         Optional<RuntimeException> result =
-                detection.checkRowIdExistence(
-                        baseEntries, deltaEntries, 14L, 
Snapshot.CommitKind.COMPACT);
+                detection.checkConflicts(
+                        snapshot(1),
+                        baseEntries,
+                        deltaEntries,
+                        Collections.emptyList(),
+                        null,
+                        Snapshot.CommitKind.COMPACT);
         assertThat(result).isPresent();
         assertThat(result.get()).hasMessageContaining("Row ID existence 
conflict");
     }
 
     @Test
     void testCheckRowIdExistenceCompactDoesNotMergeAcrossPartitions() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
         BinaryRow partition0 = BinaryRow.singleColumn(0);
         BinaryRow partition1 = BinaryRow.singleColumn(1);
 
@@ -710,7 +1188,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdRangeConflictsReportsDedicatedFileSpanningDataFiles() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         Optional<RuntimeException> exception =
                 detection.checkConflicts(
@@ -732,9 +1210,30 @@ class ConflictDetectionTest {
                 .hasMessageContaining("f2");
     }
 
+    @Test
+    void testCheckRowIdRangeConflictsRejectsOverlappingNormalFiles() {
+        DataEvolutionConflictDetection detection = createConflictDetection();
+
+        Optional<RuntimeException> exception =
+                detection.checkConflicts(
+                        snapshot(1),
+                        
Collections.singletonList(createFileEntryWithRowId("base", ADD, 0L, 5L)),
+                        Collections.singletonList(
+                                createFileEntryWithRowId("compacted", ADD, 2L, 
2L)),
+                        Collections.emptyList(),
+                        null,
+                        Snapshot.CommitKind.COMPACT);
+
+        assertThat(exception).isPresent();
+        assertThat(exception.get())
+                .hasMessageContaining("multiple 'MERGE INTO' and 'COMPACT' 
operations")
+                .hasMessageContaining("base")
+                .hasMessageContaining("compacted");
+    }
+
     @Test
     void testCheckRowIdRangeConflictsAllowsAdjacentDataFiles() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         Optional<RuntimeException> exception =
                 detection.checkConflicts(
@@ -752,7 +1251,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckRowIdRangeConflictsAllowsDedicatedFileCoveredByOneDataFile() 
{
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         Optional<RuntimeException> exception =
                 detection.checkConflicts(
@@ -796,7 +1295,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckGlobalIndexRowIdExistenceNoConflict() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         Optional<RuntimeException> exception =
                 detection.checkConflicts(
@@ -815,7 +1314,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckGlobalIndexRowIdExistenceBaseFileRemoved() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         Optional<RuntimeException> exception =
                 detection.checkConflicts(
@@ -836,7 +1335,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckGlobalIndexRowIdExistenceByPartitionAndBucket() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
         BinaryRow partition0 = BinaryRow.singleColumn(0);
         BinaryRow partition1 = BinaryRow.singleColumn(1);
 
@@ -869,7 +1368,7 @@ class ConflictDetectionTest {
 
     @Test
     void testCheckGlobalIndexRowIdExistenceSkipsDeleteIndexEntry() {
-        ConflictDetection detection = createConflictDetection();
+        DataEvolutionConflictDetection detection = createConflictDetection();
 
         Optional<RuntimeException> exception =
                 detection.checkConflicts(
@@ -884,20 +1383,60 @@ class ConflictDetectionTest {
         assertThat(exception).isNotPresent();
     }
 
-    private ConflictDetection createConflictDetection() {
-        return new ConflictDetection(
+    private DataEvolutionConflictDetection createConflictDetection() {
+        return (DataEvolutionConflictDetection) createConflictDetection(null, 
true, false);
+    }
+
+    private ConflictDetection createConflictDetection(
+            @Nullable CommitScanner scanner,
+            boolean dataEvolutionEnabled,
+            boolean primaryKeyTable) {
+        return createConflictDetection(scanner, dataEvolutionEnabled, 
primaryKeyTable, false, null);
+    }
+
+    private ConflictDetection createConflictDetection(
+            @Nullable CommitScanner scanner,
+            boolean dataEvolutionEnabled,
+            boolean primaryKeyTable,
+            boolean pkClusteringOverride,
+            @Nullable SnapshotManager snapshotManager) {
+        return ConflictDetection.create(
                 "test-table",
                 "test-user",
                 RowType.of(),
                 null,
-                null,
+                primaryKeyTable ? (left, right) -> 0 : null,
                 BucketMode.HASH_FIXED,
                 false,
-                true,
-                false,
+                dataEvolutionEnabled,
+                pkClusteringOverride,
                 null,
-                null,
-                null);
+                snapshotManager,
+                scanner);
+    }
+
+    private CommitFailRetryResult commitFailRetryResult(
+            @Nullable Snapshot latestSnapshot, @Nullable List<SimpleFileEntry> 
baseDataFiles) {
+        return (CommitFailRetryResult)
+                RetryCommitResult.forCommitFail(
+                        latestSnapshot,
+                        baseDataFiles,
+                        new RuntimeException("expected test retry"),
+                        null);
+    }
+
+    private ManifestEntry manifestEntry(
+            FileKind kind, String fileName, @Nullable Long firstRowId, 
@Nullable Range rowIdRange) {
+        ManifestEntry entry = mock(ManifestEntry.class);
+        DataFileMeta file = mock(DataFileMeta.class);
+        when(entry.kind()).thenReturn(kind);
+        when(entry.file()).thenReturn(file);
+        when(file.fileName()).thenReturn(fileName);
+        when(file.firstRowId()).thenReturn(firstRowId);
+        if (rowIdRange != null) {
+            when(file.nonNullRowIdRange()).thenReturn(rowIdRange);
+        }
+        return entry;
     }
 
     private Snapshot snapshot(long id) {
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 9e52c920b5..384f3fe2a3 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
@@ -81,6 +81,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 
 import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
+import static 
org.apache.paimon.errors.ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE;
 import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
 import static org.apache.paimon.types.VectorType.isVectorStoreFile;
 import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
@@ -720,6 +721,93 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
                 .doesNotContainAnyElementsOf(oldAnchorFiles);
     }
 
+    @Test
+    public void testStaleMaterializeRejectedAfterConcurrentUpdate() throws 
Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        writeBaseRows(table);
+        commitDeletionVectors(table, DEFAULT_DV_SPECS);
+
+        Snapshot materializeSnapshot = table.latestSnapshot().get();
+        List<CommitMessage> staleMaterializeMessages =
+                prepareMaterializeDeletionVectors(table, materializeSnapshot, 
null);
+
+        RowType writeType = 
table.rowType().project(Collections.singletonList("f2"));
+        List<CommitMessage> concurrentUpdateMessages = new ArrayList<>();
+        for (int batch = 0; batch < 3; batch++) {
+            BatchWriteBuilder builder = table.newBatchWriteBuilder();
+            try (BatchTableWrite write = 
builder.newWrite().withWriteType(writeType)) {
+                for (int rowId = batch * 5; rowId < batch * 5 + 5; rowId++) {
+                    
write.write(GenericRow.of(BinaryString.fromString("concurrent-" + rowId)));
+                }
+                List<CommitMessage> messages = write.prepareCommit();
+                setFirstRowId(messages, batch * 5L);
+                concurrentUpdateMessages.addAll(messages);
+            }
+        }
+        commit(table, concurrentUpdateMessages);
+
+        List<String> concurrentValues = expectedProjectedStrings("concurrent", 
FULL_RANGE);
+        
assertThat(readProjectedStrings(table.newReadBuilder().withProjection(new int[] 
{2})))
+                .containsExactlyElementsOf(concurrentValues);
+        long updateSnapshotId = table.latestSnapshot().get().id();
+
+        assertThatThrownBy(
+                        () ->
+                                commitMaterializeDeletionVectors(
+                                        table,
+                                        materializeSnapshot,
+                                        staleMaterializeMessages,
+                                        "test-stale-materialize"))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining(DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE);
+
+        
assertThat(table.latestSnapshot().get().id()).isEqualTo(updateSnapshotId);
+        
assertThat(readProjectedStrings(table.newReadBuilder().withProjection(new int[] 
{2})))
+                .containsExactlyElementsOf(concurrentValues);
+    }
+
+    @Test
+    public void testStaleMaterializeAllowsNonOverlappingConcurrentUpdate() 
throws Exception {
+        FileStoreTable table =
+                
createPartitionedReassignTable("non_overlapping_materialize_update_table", 
false);
+        writePartitionRows(table, "a", 0, 1, 2, 3, 4);
+        writePartitionRows(table, "b", 5, 6, 7, 8, 9);
+
+        BinaryRow partitionA = partition(table, "a");
+        BinaryRow partitionB = partition(table, "b");
+        commitDeletionVectors(
+                table, partitionA, Collections.singletonList(new DvSpec(new 
Range(0, 4), 1)));
+
+        Snapshot materializeSnapshot = table.latestSnapshot().get();
+        List<CommitMessage> staleMaterializeMessages =
+                prepareMaterializeDeletionVectors(table, materializeSnapshot, 
null);
+
+        writePartialStrings(table, "b", 5L, 5, 6, 7, 8, 9);
+        long updateSnapshotId = table.latestSnapshot().get().id();
+
+        commitMaterializeDeletionVectors(
+                table, materializeSnapshot, staleMaterializeMessages, 
"test-stale-materialize");
+
+        
assertThat(table.latestSnapshot().get().id()).isGreaterThan(updateSnapshotId);
+        assertThat(table.latestSnapshot().get().commitKind())
+                .isEqualTo(Snapshot.CommitKind.COMPACT);
+        assertThat(anchorFilesByRange(table, partitionA).keySet())
+                .containsExactly(new Range(10, 13));
+        assertThat(anchorFilesByRange(table, 
partitionB).keySet()).containsExactly(new Range(5, 9));
+        assertThat(readPartitionedRows(table))
+                .containsExactlyInAnyOrder(
+                        "a|0|base-0",
+                        "a|2|base-2",
+                        "a|3|base-3",
+                        "a|4|base-4",
+                        "b|5|updated-5",
+                        "b|6|updated-6",
+                        "b|7|updated-7",
+                        "b|8|updated-8",
+                        "b|9|updated-9");
+    }
+
     @Test
     public void testMaterializeKeepsSiblingDeletionVectorsInTouchedIndexFile() 
throws Exception {
         createTableDefault();
@@ -1133,6 +1221,14 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
     private void materializeDeletionVectors(FileStoreTable table, Integer 
deletionFilesPerBatch)
             throws Exception {
         Snapshot snapshot = table.latestSnapshot().get();
+        List<CommitMessage> commitMessages =
+                prepareMaterializeDeletionVectors(table, snapshot, 
deletionFilesPerBatch);
+        commitMaterializeDeletionVectors(table, snapshot, commitMessages, 
"test-materialize-dv");
+    }
+
+    private List<CommitMessage> prepareMaterializeDeletionVectors(
+            FileStoreTable table, Snapshot snapshot, Integer 
deletionFilesPerBatch)
+            throws Exception {
         DataEvolutionDeletionVectorMaterializeCoordinator coordinator =
                 deletionFilesPerBatch == null
                         ? new 
DataEvolutionDeletionVectorMaterializeCoordinator(
@@ -1154,7 +1250,16 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
         commitMessages.addAll(
                 new DataEvolutionCompactionCommitPreparation(table, snapshot)
                         .prepare(commitMessages));
-        try (TableCommitImpl commit = table.newCommit("test-materialize-dv")) {
+        return commitMessages;
+    }
+
+    private void commitMaterializeDeletionVectors(
+            FileStoreTable table,
+            Snapshot snapshot,
+            List<CommitMessage> commitMessages,
+            String commitUser)
+            throws Exception {
+        try (TableCommitImpl commit = table.newCommit(commitUser)) {
             commit.rowIdCheckConflictForMaterializeDvCompaction(snapshot.id())
                     .commit(commitMessages);
         }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
index beec22b2b0..c90c69c8cb 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
@@ -44,6 +44,7 @@ import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.BatchWriteBuilderImpl;
 import org.apache.paimon.table.sink.CommitMessage;
 import org.apache.paimon.table.sink.CommitMessageImpl;
 import org.apache.paimon.table.source.DataSplit;
@@ -71,6 +72,7 @@ import java.util.OptionalLong;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 
+import static 
org.apache.paimon.errors.ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE;
 import static org.apache.paimon.stats.SimpleStats.EMPTY_STATS;
 import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
 import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
@@ -1175,6 +1177,39 @@ public class DataEvolutionTableTest extends 
DataEvolutionTestBase {
                         Arrays.asList("0|base-0", "10|updated-10", 
"11|updated-11", "20|base-20"));
     }
 
+    @Test
+    public void testConcurrentSameColumnPartialUpdateConflict() throws 
Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        long firstRowId = writeFullRows(table, 10, 11);
+        long readSnapshotId = table.latestSnapshot().get().id();
+
+        RowType writeType = 
table.rowType().project(Collections.singletonList("f2"));
+        BatchWriteBuilderImpl staleBuilder = (BatchWriteBuilderImpl) 
table.newBatchWriteBuilder();
+        List<CommitMessage> staleMessages;
+        try (BatchTableWrite write = 
staleBuilder.newWrite().withWriteType(writeType)) {
+            write.write(GenericRow.of(BinaryString.fromString("stale-10")));
+            write.write(GenericRow.of(BinaryString.fromString("stale-11")));
+            staleMessages = write.prepareCommit();
+            setFirstRowId(staleMessages, firstRowId);
+        }
+
+        updateF2(table, firstRowId, 100, 101);
+        long concurrentSnapshotId = table.latestSnapshot().get().id();
+        staleBuilder.rowIdCheckConflict(readSnapshotId);
+
+        assertThatThrownBy(
+                        () -> {
+                            try (BatchTableCommit commit = 
staleBuilder.newCommit()) {
+                                commit.commit(staleMessages);
+                            }
+                        })
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining(DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE);
+        
assertThat(table.latestSnapshot().get().id()).isEqualTo(concurrentSnapshotId);
+        
assertThat(readF0AndF2(table)).isEqualTo(Arrays.asList("10|updated-100", 
"11|updated-101"));
+    }
+
     @Test
     public void 
testCompactPreservesConcurrentPartialUpdateWithinCandidateRange() throws 
Exception {
         createTableDefault();

Reply via email to