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 a777c1b2b4 [core] Add conflict detection for concurrent merge-into & 
compaction (#8964)
a777c1b2b4 is described below

commit a777c1b2b49ca89e60b70e16715bf3ac483120ae
Author: Faiz <[email protected]>
AuthorDate: Mon Aug 3 19:05:46 2026 +0800

    [core] Add conflict detection for concurrent merge-into & compaction (#8964)
---
 .../apache/paimon/operation/FileStoreCommit.java   |   3 +
 .../paimon/operation/FileStoreCommitImpl.java      |  54 +++++--
 .../paimon/operation/commit/ConflictDetection.java |  46 +++++-
 .../commit/RowIdColumnConflictChecker.java         |   8 +-
 .../operation/commit/RowIdConflictChecker.java     |  35 +++++
 .../commit/RowIdRangeConflictChecker.java          |  56 ++++++++
 .../apache/paimon/table/sink/InnerTableCommit.java |   3 +
 .../apache/paimon/table/sink/TableCommitImpl.java  |   7 +
 .../operation/commit/ConflictDetectionTest.java    |  22 +++
 .../commit/RowIdRangeConflictCheckerTest.java      |  83 +++++++++++
 .../table/DataEvolutionDeletionVectorTest.java     | 156 +++++++++++++++++++++
 .../flink/sink/DataEvolutionTableCompactSink.java  |  14 +-
 .../paimon/spark/procedure/CompactProcedure.java   |  12 ++
 13 files changed, 478 insertions(+), 21 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
index a9936833a9..b039ffb9e9 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
@@ -46,6 +46,9 @@ public interface FileStoreCommit extends AutoCloseable {
 
     FileStoreCommit rowIdCheckConflict(@Nullable Long rowIdCheckFromSnapshot);
 
+    FileStoreCommit rowIdCheckConflictForMaterializeDvCompaction(
+            @Nullable Long rowIdCheckFromSnapshot);
+
     FileStoreCommit withOperation(Snapshot.Operation operation);
 
     /** Find out which committables need to be retried when recovering from 
the failure. */
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 e07fcb443f..80f4ffba46 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
@@ -54,6 +54,8 @@ 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;
@@ -106,13 +108,16 @@ import java.util.stream.Collectors;
 
 import static java.util.Collections.emptyList;
 import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
 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;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
@@ -264,6 +269,14 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         return this;
     }
 
+    @Override
+    public FileStoreCommit rowIdCheckConflictForMaterializeDvCompaction(
+            @Nullable Long rowIdCheckFromSnapshot) {
+        
this.conflictDetection.setRowIdCheckFromSnapshotForMaterializeDvCompaction(
+                rowIdCheckFromSnapshot);
+        return this;
+    }
+
     @Override
     public FileStoreCommit withOperation(Snapshot.Operation operation) {
         this.operation = operation;
@@ -346,7 +359,7 @@ public class FileStoreCommitImpl implements FileStoreCommit 
{
                     checkAppendFiles = true;
                     allowRollback = true;
                 }
-                if (conflictDetection.hasRowIdCheckFromSnapshot()) {
+                if 
(conflictDetection.shouldCheckRowIdFromSnapshot(commitKind)) {
                     checkAppendFiles = true;
                     allowRollback = true;
                 }
@@ -1010,14 +1023,35 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                                 .filter(entry -> 
!baseIdentifiers.contains(entry.identifier()))
                                 .collect(Collectors.toList());
             }
-            RowIdColumnConflictChecker rowIdColumnConflictChecker = null;
-            if (conflictDetection.hasRowIdCheckFromSnapshot()) {
-                rowIdColumnConflictChecker =
-                        RowIdColumnConflictChecker.fromDataFiles(
-                                schemaManager,
-                                deltaFiles.stream()
-                                        .map(ManifestEntry::file)
-                                        .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);
+                }
             }
             Optional<RuntimeException> exception =
                     conflictDetection.checkConflicts(
@@ -1025,7 +1059,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                             baseDataFiles,
                             SimpleFileEntry.from(deltaFiles),
                             indexFiles,
-                            rowIdColumnConflictChecker,
+                            rowIdConflictChecker,
                             commitKind);
             if (exception.isPresent()) {
                 if (allowRollback && rollback != null) {
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 4cdc5f997b..99bae20226 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
@@ -67,6 +67,7 @@ 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;
@@ -101,6 +102,7 @@ public class ConflictDetection {
 
     private @Nullable PartitionExpire partitionExpire;
     private @Nullable Long rowIdCheckFromSnapshot = null;
+    private @Nullable RowIdConflictChecker.TriggerSource 
rowIdConflictCheckTriggerSource = null;
 
     public ConflictDetection(
             String tableName,
@@ -130,11 +132,34 @@ public class ConflictDetection {
     }
 
     public void setRowIdCheckFromSnapshot(@Nullable Long 
rowIdCheckFromSnapshot) {
+        setRowIdCheckFromSnapshot(
+                rowIdCheckFromSnapshot, 
RowIdConflictChecker.TriggerSource.DATA_EVOLUTION_DML);
+    }
+
+    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;
+    }
+
+    public boolean shouldCheckRowIdFromSnapshot(CommitKind commitKind) {
+        return rowIdCheckFromSnapshot != null
+                && (rowIdConflictCheckTriggerSource != 
MATERIALIZE_DV_COMPACTION
+                        || commitKind == CommitKind.COMPACT);
     }
 
-    public boolean hasRowIdCheckFromSnapshot() {
-        return rowIdCheckFromSnapshot != null;
+    public RowIdConflictChecker.TriggerSource 
rowIdConflictCheckTriggerSource() {
+        checkState(
+                rowIdConflictCheckTriggerSource != null,
+                "Row ID conflict check trigger source is not set.");
+        return rowIdConflictCheckTriggerSource;
     }
 
     @Nullable
@@ -166,7 +191,7 @@ public class ConflictDetection {
             List<SimpleFileEntry> baseEntries,
             List<SimpleFileEntry> deltaEntries,
             List<IndexManifestEntry> deltaIndexEntries,
-            @Nullable RowIdColumnConflictChecker rowIdColumnConflictChecker,
+            @Nullable RowIdConflictChecker rowIdConflictChecker,
             CommitKind commitKind) {
         String baseCommitUser = latestSnapshot.commitUser();
         if (deletionVectorsEnabled && 
bucketMode.equals(BucketMode.BUCKET_UNAWARE)) {
@@ -247,7 +272,7 @@ public class ConflictDetection {
         }
 
         return checkForRowIdFromSnapshot(
-                latestSnapshot, deltaEntries, deltaIndexEntries, 
rowIdColumnConflictChecker);
+                latestSnapshot, deltaEntries, deltaIndexEntries, 
rowIdConflictChecker);
     }
 
     public <T extends FileEntry> Map<BinaryRow, Integer> 
collectUncheckedFixedBucketPartitions(
@@ -580,14 +605,14 @@ public class ConflictDetection {
             Snapshot latestSnapshot,
             List<SimpleFileEntry> deltaEntries,
             List<IndexManifestEntry> deltaIndexEntries,
-            @Nullable RowIdColumnConflictChecker columnChecker) {
+            @Nullable RowIdConflictChecker conflictChecker) {
         if (!dataEvolutionEnabled) {
             return Optional.empty();
         }
         if (rowIdCheckFromSnapshot == null) {
             return Optional.empty();
         }
-        if (columnChecker == null || columnChecker.isEmpty()) {
+        if (conflictChecker == null || conflictChecker.isEmpty()) {
             return Optional.empty();
         }
 
@@ -607,10 +632,13 @@ public class ConflictDetection {
             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
-                        && columnChecker.conflictsWith(file)) {
+                        && conflictChecker.conflictsWith(file)) {
                     LOG.debug(
                             "Data evolution row id conflict detected for table 
{}, commit user {}, "
                                     + "snapshot {}, file {}.",
@@ -628,6 +656,10 @@ public class ConflictDetection {
         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) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdColumnConflictChecker.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdColumnConflictChecker.java
index b2f8740f52..f5d00a1e89 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdColumnConflictChecker.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdColumnConflictChecker.java
@@ -46,7 +46,7 @@ import java.util.stream.Collectors;
  *       columns also overlap, return conflicting result.
  * </ol>
  */
-public class RowIdColumnConflictChecker {
+public class RowIdColumnConflictChecker implements RowIdConflictChecker {
 
     private final SchemaManager schemaManager;
     private final List<WriteRange> writeRanges;
@@ -122,7 +122,8 @@ public class RowIdColumnConflictChecker {
         return new Range(from, to);
     }
 
-    boolean isEmpty() {
+    @Override
+    public boolean isEmpty() {
         return writeRanges.isEmpty();
     }
 
@@ -134,7 +135,8 @@ public class RowIdColumnConflictChecker {
      * @param file committed incremental data file
      * @return true if conflict
      */
-    boolean conflictsWith(DataFileMeta file) {
+    @Override
+    public boolean conflictsWith(DataFileMeta file) {
         Long firstRowId = file.firstRowId();
         if (firstRowId == null) {
             return false;
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
new file mode 100644
index 0000000000..e5e9808946
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdConflictChecker.java
@@ -0,0 +1,35 @@
+/*
+ * 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.io.DataFileMeta;
+
+/** Detects row ID conflicts between committing files and a historical data 
file. */
+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/main/java/org/apache/paimon/operation/commit/RowIdRangeConflictChecker.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdRangeConflictChecker.java
new file mode 100644
index 0000000000..cf6bea0a51
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdRangeConflictChecker.java
@@ -0,0 +1,56 @@
+/*
+ * 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.io.DataFileMeta;
+import org.apache.paimon.utils.RowRangeIndex;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/** Detects row ID conflicts solely by row range overlap. */
+public class RowIdRangeConflictChecker implements RowIdConflictChecker {
+
+    private final RowRangeIndex rowRangeIndex;
+
+    private RowIdRangeConflictChecker(List<DataFileMeta> deltaFiles) {
+        this.rowRangeIndex =
+                RowRangeIndex.create(
+                        deltaFiles.stream()
+                                .filter(file -> file.firstRowId() != null)
+                                .map(DataFileMeta::nonNullRowIdRange)
+                                .collect(Collectors.toList()));
+    }
+
+    public static RowIdRangeConflictChecker fromDataFiles(List<DataFileMeta> 
deltaFiles) {
+        return new RowIdRangeConflictChecker(deltaFiles);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return rowRangeIndex.ranges().isEmpty();
+    }
+
+    @Override
+    public boolean conflictsWith(DataFileMeta file) {
+        return file.firstRowId() != null
+                && rowRangeIndex.intersects(
+                        file.nonNullRowIdRange().from, 
file.nonNullRowIdRange().to);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/InnerTableCommit.java 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/InnerTableCommit.java
index f5bb81d667..43f98d0e79 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/InnerTableCommit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/InnerTableCommit.java
@@ -58,6 +58,9 @@ public interface InnerTableCommit extends StreamTableCommit, 
BatchTableCommit {
 
     InnerTableCommit rowIdCheckConflict(@Nullable Long rowIdCheckFromSnapshot);
 
+    InnerTableCommit rowIdCheckConflictForMaterializeDvCompaction(
+            @Nullable Long rowIdCheckFromSnapshot);
+
     @Override
     InnerTableCommit withMetricRegistry(MetricRegistry registry);
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
index b66cca67e3..7e47041db6 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
@@ -182,6 +182,13 @@ public class TableCommitImpl implements InnerTableCommit {
         return this;
     }
 
+    @Override
+    public TableCommitImpl rowIdCheckConflictForMaterializeDvCompaction(
+            @Nullable Long rowIdCheckFromSnapshot) {
+        
commit.rowIdCheckConflictForMaterializeDvCompaction(rowIdCheckFromSnapshot);
+        return this;
+    }
+
     @Override
     public TableCommitImpl withOperation(Snapshot.Operation operation) {
         commit.withOperation(operation);
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 cfc2963d08..d73cfac0e0 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
@@ -398,6 +398,28 @@ class ConflictDetectionTest {
                 .isFalse();
     }
 
+    @Test
+    void testMaterializeDvRowIdCheckOnlyAppliesToCompactCommit() {
+        ConflictDetection detection = createConflictDetection();
+
+        detection.setRowIdCheckFromSnapshotForMaterializeDvCompaction(1L);
+        
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.APPEND)).isFalse();
+        
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.OVERWRITE)).isFalse();
+        
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.COMPACT)).isTrue();
+        
assertThat(detection.shouldCheckHistoricalRowIdEntry(FileKind.ADD)).isTrue();
+        
assertThat(detection.shouldCheckHistoricalRowIdEntry(FileKind.DELETE)).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();
+
+        detection.setRowIdCheckFromSnapshot(null);
+        
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.APPEND)).isFalse();
+        
assertThat(detection.shouldCheckRowIdFromSnapshot(Snapshot.CommitKind.COMPACT)).isFalse();
+    }
+
     @Test
     void testChangedPartitionsIncludesGlobalIndexFiles() {
         BinaryRow partition = BinaryRow.singleColumn(1);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/RowIdRangeConflictCheckerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/RowIdRangeConflictCheckerTest.java
new file mode 100644
index 0000000000..419410a265
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/RowIdRangeConflictCheckerTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.io.DataFileMeta;
+import org.apache.paimon.stats.SimpleStats;
+
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RowIdRangeConflictCheckerTest {
+
+    @Test
+    void testDetectsOverlappingRowRanges() {
+        RowIdRangeConflictChecker checker =
+                RowIdRangeConflictChecker.fromDataFiles(
+                        Arrays.asList(file("first", 0L, 5L), file("second", 
10L, 5L)));
+
+        assertThat(checker.conflictsWith(file("same", 0L, 5L))).isTrue();
+        assertThat(checker.conflictsWith(file("overlap", 4L, 7L))).isTrue();
+        assertThat(checker.conflictsWith(file("contained", 11L, 2L))).isTrue();
+    }
+
+    @Test
+    void testAllowsDisjointAndAdjacentRowRanges() {
+        RowIdRangeConflictChecker checker =
+                RowIdRangeConflictChecker.fromDataFiles(
+                        Collections.singletonList(file("current", 5L, 5L)));
+
+        assertThat(checker.conflictsWith(file("before", 0L, 5L))).isFalse();
+        assertThat(checker.conflictsWith(file("after", 10L, 5L))).isFalse();
+    }
+
+    @Test
+    void testIgnoresFilesWithoutRowIds() {
+        RowIdRangeConflictChecker checker =
+                RowIdRangeConflictChecker.fromDataFiles(
+                        Collections.singletonList(file("current", null, 5L)));
+
+        assertThat(checker.isEmpty()).isTrue();
+        assertThat(checker.conflictsWith(file("historical", 0L, 
5L))).isFalse();
+    }
+
+    private DataFileMeta file(String fileName, @Nullable Long firstRowId, long 
rowCount) {
+        return DataFileMeta.forAppend(
+                fileName,
+                0L,
+                rowCount,
+                SimpleStats.EMPTY_STATS,
+                0L,
+                0L,
+                0L,
+                Collections.emptyList(),
+                null,
+                null,
+                null,
+                null,
+                firstRowId,
+                null);
+    }
+}
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 1de5d51a4b..df0b646cc8 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
@@ -53,6 +53,7 @@ import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.sink.CommitMessage;
 import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.sink.TableCommitImpl;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.DeletionFile;
 import org.apache.paimon.table.source.EndOfScanException;
@@ -79,6 +80,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;
@@ -598,6 +600,143 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
                 .doesNotContainAnyElementsOf(oldAnchorFiles);
     }
 
+    @Test
+    public void testStaleMaterializeCompactionRejectedAfterConcurrentUpdate() 
throws Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        writeBaseRows(table);
+        commitDeletionVectors(table, DEFAULT_DV_SPECS);
+
+        Map<String, String> dynamicOptions = new HashMap<>();
+        dynamicOptions.put(CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "2");
+        
dynamicOptions.put(CoreOptions.DATA_EVOLUTION_COMPACTION_REWRITE_ROW_IDS.key(), 
"true");
+        FileStoreTable compactTable = table.copy(dynamicOptions);
+        Snapshot compactSnapshot = compactTable.latestSnapshot().get();
+        DataEvolutionCompactCoordinator coordinator =
+                new DataEvolutionCompactCoordinator(compactTable, false, 
false, compactSnapshot);
+        List<CommitMessage> staleMaterializeMessages = new ArrayList<>();
+        try {
+            while (true) {
+                for (DataEvolutionCompactTask task : coordinator.plan()) {
+                    staleMaterializeMessages.add(
+                            task.doCompact(compactTable, 
"test-stale-materialize"));
+                }
+            }
+        } catch (EndOfScanException ignored) {
+        }
+        assertThat(staleMaterializeMessages).isNotEmpty();
+        staleMaterializeMessages.addAll(
+                new DataEvolutionCompactionCommitPreparation(compactTable, 
compactSnapshot)
+                        .prepare(staleMaterializeMessages));
+
+        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);
+
+        
assertThat(readProjectedStrings(table.newReadBuilder().withProjection(new int[] 
{2})))
+                
.containsExactlyElementsOf(expectedProjectedStrings("concurrent", FULL_RANGE));
+        long updateSnapshotId = table.latestSnapshot().get().id();
+
+        assertThatThrownBy(
+                        () -> {
+                            try (TableCommitImpl commit =
+                                    
compactTable.newCommit("test-stale-materialize")) {
+                                
commit.rowIdCheckConflictForMaterializeDvCompaction(
+                                                compactSnapshot.id())
+                                        .commit(staleMaterializeMessages);
+                            }
+                        })
+                .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(expectedProjectedStrings("concurrent", FULL_RANGE));
+    }
+
+    @Test
+    public void 
testStaleMaterializeCompactionAllowsNonOverlappingConcurrentUpdate()
+            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");
+        assertThat(anchorFilesByRange(table, 
partitionA).keySet()).containsExactly(new Range(0, 4));
+        assertThat(anchorFilesByRange(table, 
partitionB).keySet()).containsExactly(new Range(5, 9));
+        commitDeletionVectors(
+                table, partitionA, Collections.singletonList(new DvSpec(new 
Range(0, 4), 1)));
+
+        Map<String, String> dynamicOptions = new HashMap<>();
+        dynamicOptions.put(CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "2");
+        
dynamicOptions.put(CoreOptions.DATA_EVOLUTION_COMPACTION_REWRITE_ROW_IDS.key(), 
"true");
+        FileStoreTable compactTable = table.copy(dynamicOptions);
+        Snapshot compactSnapshot = compactTable.latestSnapshot().get();
+        PartitionPredicate partitionPredicate =
+                PartitionPredicate.fromMaps(
+                        table.schema().logicalPartitionType(),
+                        
Collections.singletonList(Collections.singletonMap("pt", "a")),
+                        table.coreOptions().partitionDefaultName());
+        DataEvolutionCompactCoordinator coordinator =
+                new DataEvolutionCompactCoordinator(
+                        compactTable, partitionPredicate, false, false, 
compactSnapshot);
+        List<CommitMessage> staleMaterializeMessages = new ArrayList<>();
+        try {
+            while (true) {
+                for (DataEvolutionCompactTask task : coordinator.plan()) {
+                    assertThat(task.type())
+                            
.isEqualTo(DataEvolutionCompactTask.TaskType.MATERIALIZE_DELETION);
+                    staleMaterializeMessages.add(
+                            task.doCompact(compactTable, 
"test-stale-materialize"));
+                }
+            }
+        } catch (EndOfScanException ignored) {
+        }
+        assertThat(staleMaterializeMessages).isNotEmpty();
+        staleMaterializeMessages.addAll(
+                new DataEvolutionCompactionCommitPreparation(compactTable, 
compactSnapshot)
+                        .prepare(staleMaterializeMessages));
+
+        writePartialStrings(table, "b", 5L, 5, 6, 7, 8, 9);
+        long updateSnapshotId = table.latestSnapshot().get().id();
+
+        try (TableCommitImpl commit = 
compactTable.newCommit("test-stale-materialize")) {
+            
commit.rowIdCheckConflictForMaterializeDvCompaction(compactSnapshot.id())
+                    .commit(staleMaterializeMessages);
+        }
+
+        
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 
testMaterializeCompactionUsesRemainingSizeForLargeDeletedRange() throws 
Exception {
         createTableDefault();
@@ -867,6 +1006,23 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
         return rows;
     }
 
+    private static List<String> readPartitionedRows(FileStoreTable table) 
throws IOException {
+        ReadBuilder readBuilder = table.newReadBuilder();
+        List<String> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+            reader.forEachRemaining(
+                    row ->
+                            rows.add(
+                                    row.getString(0)
+                                            + "|"
+                                            + row.getInt(1)
+                                            + "|"
+                                            + row.getString(2)));
+        }
+        return rows;
+    }
+
     private static Map<String, Range> relativeFileRanges(
             FileStoreTable table, BinaryRow partition) {
         List<DataFileMeta> dataFiles = currentDataFiles(table, partition);
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionTableCompactSink.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionTableCompactSink.java
index 5fc4320e4e..d57ecbe11b 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionTableCompactSink.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionTableCompactSink.java
@@ -18,10 +18,12 @@
 
 package org.apache.paimon.flink.sink;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
 import org.apache.paimon.manifest.ManifestCommittable;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.TableCommitImpl;
 
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.datastream.DataStreamSink;
@@ -71,7 +73,17 @@ public class DataEvolutionTableCompactSink extends 
FlinkSink<DataEvolutionCompac
 
     @Override
     protected Committer.Factory<Committable, ManifestCommittable> 
createCommitterFactory() {
-        return context -> new StoreCommitter(table, 
table.newCommit(context.commitUser()), context);
+        return context -> {
+            TableCommitImpl commit = table.newCommit(context.commitUser());
+            if (shouldCheckMaterializeDvConflict(table.coreOptions())) {
+                
commit.rowIdCheckConflictForMaterializeDvCompaction(snapshot.id());
+            }
+            return new StoreCommitter(table, commit, context);
+        };
+    }
+
+    static boolean shouldCheckMaterializeDvConflict(CoreOptions options) {
+        return options.deletionVectorsEnabled() && 
options.dataEvolutionCompactionRewriteRowIds();
     }
 
     @Override
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
index 13a71d773c..59a92cd269 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
@@ -536,6 +536,7 @@ public class CompactProcedure extends BaseProcedure {
                     LOG.info("Task plan is empty, no compact job to execute.");
                     continue;
                 }
+                boolean containsMaterializeDeletion = 
containsMaterializeDeletion(compactionTasks);
 
                 DataEvolutionCompactTaskSerializer serializer =
                         new DataEvolutionCompactTaskSerializer();
@@ -576,6 +577,9 @@ public class CompactProcedure extends BaseProcedure {
 
                 List<byte[]> serializedMessages = new 
ArrayList<>(commitMessageJavaRDD.collect());
                 try (TableCommitImpl commit = table.newCommit(commitUser)) {
+                    if (containsMaterializeDeletion) {
+                        
commit.rowIdCheckConflictForMaterializeDvCompaction(snapshot.id());
+                    }
                     List<CommitMessage> messages =
                             deserializeCommitMessagesAndReleaseSerializedBytes(
                                     messageSerializerser, serializedMessages);
@@ -592,6 +596,14 @@ public class CompactProcedure extends BaseProcedure {
         }
     }
 
+    static boolean containsMaterializeDeletion(List<DataEvolutionCompactTask> 
compactionTasks) {
+        return compactionTasks.stream()
+                .anyMatch(
+                        task ->
+                                task.type()
+                                        == 
DataEvolutionCompactTask.TaskType.MATERIALIZE_DELETION);
+    }
+
     private static List<CommitMessage> 
deserializeCommitMessagesAndReleaseSerializedBytes(
             CommitMessageSerializer serializer, List<byte[]> 
serializedMessages)
             throws IOException {

Reply via email to