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 8d26890f1a [core] detect concurrent row-id reassignment and 
data-evolution compaction (#8773)
8d26890f1a is described below

commit 8d26890f1abbd75fe73105d380c1b95a1d768fb2
Author: Faiz <[email protected]>
AuthorDate: Tue Jul 21 15:44:18 2026 +0800

    [core] detect concurrent row-id reassignment and data-evolution compaction 
(#8773)
---
 .../paimon/operation/FileStoreCommitImpl.java      |   7 +-
 .../paimon/operation/commit/ConflictDetection.java |  98 ++++--
 .../operation/commit/ConflictDetectionTest.java    | 121 ++++++-
 .../table/DataEvolutionDeletionVectorTest.java     | 365 ++++++++++++++++++++-
 4 files changed, 545 insertions(+), 46 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index 07505fa3e4..b440320212 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
@@ -891,6 +891,7 @@ public class FileStoreCommitImpl implements FileStoreCommit 
{
 
         // Check if the commit has been completed. At this point, there will 
be no more repeated
         // commits and just return success
+        boolean hasOverwriteSinceLastAttempt = false;
         if (retryResult instanceof CommitFailRetryResult && latestSnapshot != 
null) {
             CommitFailRetryResult commitFailRetry = (CommitFailRetryResult) 
retryResult;
             Map<Long, Snapshot> snapshotCache = new HashMap<>();
@@ -903,6 +904,7 @@ public class FileStoreCommitImpl implements FileStoreCommit 
{
             }
             for (long i = startCheckSnapshot; i <= latestSnapshot.id(); i++) {
                 Snapshot snapshot = snapshotCache.computeIfAbsent(i, 
snapshotManager::snapshot);
+                hasOverwriteSinceLastAttempt |= snapshot.commitKind() == 
CommitKind.OVERWRITE;
                 if (snapshot.commitUser().equals(commitUser)
                         && snapshot.commitIdentifier() == identifier
                         && snapshot.commitKind() == commitKind) {
@@ -959,9 +961,12 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                     retryResult instanceof CommitFailRetryResult
                             ? (CommitFailRetryResult) retryResult
                             : null;
+            // 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 (commitFailRetry != null
                     && commitFailRetry.latestSnapshot != null
-                    && commitFailRetry.baseDataFiles != null) {
+                    && commitFailRetry.baseDataFiles != null
+                    && !hasOverwriteSinceLastAttempt) {
                 baseDataFiles = new ArrayList<>(commitFailRetry.baseDataFiles);
                 List<SimpleFileEntry> incremental =
                         scanner.readIncrementalChanges(
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 a679b9d091..34c5a29a46 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
@@ -228,12 +228,10 @@ public class ConflictDetection {
             return exception;
         }
 
-        if (commitKind != CommitKind.COMPACT) {
-            Long nextRowId = latestSnapshot.nextRowId();
-            exception = checkRowIdExistence(baseEntries, deltaEntries, 
nextRowId);
-            if (exception.isPresent()) {
-                return exception;
-            }
+        Long nextRowId = latestSnapshot.nextRowId();
+        exception = checkRowIdExistence(baseEntries, deltaEntries, nextRowId, 
commitKind);
+        if (exception.isPresent()) {
+            return exception;
         }
 
         exception = checkRowIdRangeConflicts(commitKind, mergedEntries);
@@ -690,11 +688,66 @@ public class ConflictDetection {
     Optional<RuntimeException> checkRowIdExistence(
             List<SimpleFileEntry> baseEntries,
             List<SimpleFileEntry> deltaEntries,
-            @Nullable Long nextRowId) {
+            @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(
@@ -709,13 +762,6 @@ public class ConflictDetection {
             return Optional.empty();
         }
 
-        List<SimpleFileEntry> existingDataFiles =
-                baseEntries.stream()
-                        .filter(
-                                base ->
-                                        base.firstRowId() != null
-                                                && 
!dedicatedStorageFile(base.fileName()))
-                        .collect(Collectors.toList());
         RowRangeIndex existingIndex = rowRangeIndex(existingDataFiles, false);
 
         for (SimpleFileEntry entry : filesToCheck) {
@@ -725,23 +771,23 @@ public class ConflictDetection {
                             ? existingIndex.contains(rowRange)
                             : existingIndex.containsExactly(rowRange);
             if (!exists) {
-                return Optional.of(
-                        new RuntimeException(
-                                String.format(
-                                        "Row ID existence conflict: file '%s' 
references "
-                                                + "firstRowId=%d, rowCount=%d 
in bucket %d, "
-                                                + "but no matching file exists 
in the current snapshot. "
-                                                + "The referenced file may 
have been rewritten by a "
-                                                + "concurrent compaction or 
removed by an overwrite.",
-                                        entry.fileName(),
-                                        entry.firstRowId(),
-                                        entry.rowCount(),
-                                        entry.bucket())));
+                return Optional.of(rowIdExistenceConflict(entry));
             }
         }
         return Optional.empty();
     }
 
+    private RuntimeException rowIdExistenceConflict(SimpleFileEntry entry) {
+        return new RuntimeException(
+                String.format(
+                        "Row ID existence conflict: file '%s' references "
+                                + "firstRowId=%d, rowCount=%d in bucket %d, "
+                                + "but no matching file exists in the current 
snapshot. "
+                                + "The referenced file may have been rewritten 
by a "
+                                + "concurrent compaction or removed by an 
overwrite.",
+                        entry.fileName(), entry.firstRowId(), 
entry.rowCount(), entry.bucket()));
+    }
+
     private static boolean dedicatedStorageFile(String fileName) {
         return isBlobFile(fileName) || isVectorStoreFile(fileName);
     }
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 5be15f0dcc..cfc2963d08 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
@@ -420,7 +420,10 @@ class ConflictDetectionTest {
         List<SimpleFileEntry> deltaEntries = new ArrayList<>();
         deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));
 
-        assertThat(detection.checkRowIdExistence(baseEntries, deltaEntries, 
100L)).isEmpty();
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, 100L, 
Snapshot.CommitKind.APPEND))
+                .isEmpty();
     }
 
     @Test
@@ -433,7 +436,8 @@ class ConflictDetectionTest {
         deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));
 
         Optional<RuntimeException> result =
-                detection.checkRowIdExistence(baseEntries, deltaEntries, 100L);
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 100L, 
Snapshot.CommitKind.APPEND);
         assertThat(result).isPresent();
         assertThat(result.get().getMessage()).contains("Row ID existence 
conflict");
     }
@@ -449,7 +453,8 @@ class ConflictDetectionTest {
         deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));
 
         Optional<RuntimeException> result =
-                detection.checkRowIdExistence(baseEntries, deltaEntries, 200L);
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 200L, 
Snapshot.CommitKind.APPEND);
         assertThat(result).isPresent();
         assertThat(result.get().getMessage()).contains("Row ID existence 
conflict");
     }
@@ -466,7 +471,8 @@ class ConflictDetectionTest {
         deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 4L));
 
         Optional<RuntimeException> result =
-                detection.checkRowIdExistence(baseEntries, deltaEntries, 4L);
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 4L, 
Snapshot.CommitKind.APPEND);
         assertThat(result).isPresent();
         assertThat(result.get().getMessage()).contains("Row ID existence 
conflict");
     }
@@ -481,7 +487,10 @@ class ConflictDetectionTest {
         List<SimpleFileEntry> deltaEntries = new ArrayList<>();
         deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 2L));
 
-        assertThat(detection.checkRowIdExistence(baseEntries, deltaEntries, 
4L)).isEmpty();
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, 4L, 
Snapshot.CommitKind.APPEND))
+                .isEmpty();
     }
 
     @Test
@@ -496,7 +505,8 @@ class ConflictDetectionTest {
         deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 4L));
 
         Optional<RuntimeException> result =
-                detection.checkRowIdExistence(baseEntries, deltaEntries, 4L);
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 4L, 
Snapshot.CommitKind.APPEND);
         assertThat(result).isPresent();
         assertThat(result.get().getMessage()).contains("Row ID existence 
conflict");
     }
@@ -512,7 +522,8 @@ class ConflictDetectionTest {
         deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 3L));
 
         Optional<RuntimeException> result =
-                detection.checkRowIdExistence(baseEntries, deltaEntries, 3L);
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 3L, 
Snapshot.CommitKind.APPEND);
         assertThat(result).isPresent();
         assertThat(result.get().getMessage()).contains("Row ID existence 
conflict");
     }
@@ -528,7 +539,8 @@ class ConflictDetectionTest {
         deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 2L));
 
         Optional<RuntimeException> result =
-                detection.checkRowIdExistence(baseEntries, deltaEntries, 2L);
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 2L, 
Snapshot.CommitKind.APPEND);
         assertThat(result).isPresent();
         assertThat(result.get().getMessage()).contains("Row ID existence 
conflict");
     }
@@ -547,7 +559,10 @@ class ConflictDetectionTest {
         // newly appended file (firstRowId=100 >= nextRowId=100), should be 
skipped
         deltaEntries.add(createFileEntryWithRowId("new1", ADD, 100L, 50L));
 
-        assertThat(detection.checkRowIdExistence(baseEntries, deltaEntries, 
100L)).isEmpty();
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, 100L, 
Snapshot.CommitKind.APPEND))
+                .isEmpty();
     }
 
     @Test
@@ -559,7 +574,10 @@ class ConflictDetectionTest {
         List<SimpleFileEntry> deltaEntries = new ArrayList<>();
         deltaEntries.add(createFileEntry("f1", ADD));
 
-        assertThat(detection.checkRowIdExistence(baseEntries, deltaEntries, 
100L)).isEmpty();
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, 100L, 
Snapshot.CommitKind.APPEND))
+                .isEmpty();
     }
 
     @Test
@@ -571,7 +589,10 @@ class ConflictDetectionTest {
         List<SimpleFileEntry> deltaEntries = new ArrayList<>();
         deltaEntries.add(createFileEntryWithRowId("f1", DELETE, 0L, 100L));
 
-        assertThat(detection.checkRowIdExistence(baseEntries, deltaEntries, 
100L)).isEmpty();
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, 100L, 
Snapshot.CommitKind.APPEND))
+                .isEmpty();
     }
 
     @Test
@@ -582,7 +603,83 @@ class ConflictDetectionTest {
         List<SimpleFileEntry> deltaEntries = new ArrayList<>();
         deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));
 
-        assertThat(detection.checkRowIdExistence(baseEntries, deltaEntries, 
null)).isEmpty();
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, null, 
Snapshot.CommitKind.APPEND))
+                .isEmpty();
+    }
+
+    @Test
+    void testCheckRowIdExistenceCompactAllowsAdjacentNormalRanges() {
+        ConflictDetection detection = createConflictDetection();
+
+        List<SimpleFileEntry> baseEntries =
+                Arrays.asList(
+                        createFileEntryWithRowId("f1", ADD, 0L, 2L),
+                        createFileEntryWithRowId("f2", ADD, 2L, 2L));
+        List<SimpleFileEntry> deltaEntries =
+                
Collections.singletonList(createFileEntryWithRowId("compacted", ADD, 0L, 4L));
+
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, 4L, 
Snapshot.CommitKind.COMPACT))
+                .isEmpty();
+    }
+
+    @Test
+    void testCheckRowIdExistenceCompactAllowsBlobAcrossAdjacentNormalRanges() {
+        ConflictDetection detection = createConflictDetection();
+
+        List<SimpleFileEntry> baseEntries =
+                Arrays.asList(
+                        createFileEntryWithRowId("f1", ADD, 0L, 2L),
+                        createFileEntryWithRowId("f2", ADD, 2L, 2L));
+        List<SimpleFileEntry> deltaEntries =
+                
Collections.singletonList(createFileEntryWithRowId("compacted.blob", ADD, 0L, 
4L));
+
+        assertThat(
+                        detection.checkRowIdExistence(
+                                baseEntries, deltaEntries, 4L, 
Snapshot.CommitKind.COMPACT))
+                .isEmpty();
+    }
+
+    @Test
+    void testCheckRowIdExistenceCompactRejectsStaleRangeAfterReassign() {
+        ConflictDetection detection = createConflictDetection();
+
+        List<SimpleFileEntry> baseEntries =
+                Arrays.asList(
+                        createFileEntryWithRowId("f1", ADD, 10L, 2L),
+                        createFileEntryWithRowId("f2", ADD, 12L, 2L));
+        List<SimpleFileEntry> deltaEntries =
+                
Collections.singletonList(createFileEntryWithRowId("compacted", ADD, 0L, 4L));
+
+        Optional<RuntimeException> result =
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 14L, 
Snapshot.CommitKind.COMPACT);
+        assertThat(result).isPresent();
+        assertThat(result.get()).hasMessageContaining("Row ID existence 
conflict");
+    }
+
+    @Test
+    void testCheckRowIdExistenceCompactDoesNotMergeAcrossPartitions() {
+        ConflictDetection detection = createConflictDetection();
+        BinaryRow partition0 = BinaryRow.singleColumn(0);
+        BinaryRow partition1 = BinaryRow.singleColumn(1);
+
+        List<SimpleFileEntry> baseEntries =
+                Arrays.asList(
+                        createFileEntryWithRowId("f1", ADD, partition0, 0, 0L, 
2L),
+                        createFileEntryWithRowId("f2", ADD, partition1, 0, 2L, 
2L));
+        List<SimpleFileEntry> deltaEntries =
+                Collections.singletonList(
+                        createFileEntryWithRowId("compacted", ADD, partition0, 
0, 0L, 4L));
+
+        Optional<RuntimeException> result =
+                detection.checkRowIdExistence(
+                        baseEntries, deltaEntries, 4L, 
Snapshot.CommitKind.COMPACT);
+        assertThat(result).isPresent();
+        assertThat(result.get()).hasMessageContaining("Row ID existence 
conflict");
     }
 
     @Test
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 325079dbe7..1de5d51a4b 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
@@ -23,12 +23,14 @@ import org.apache.paimon.Snapshot;
 import org.apache.paimon.append.dataevolution.DataEvolutionCompactCoordinator;
 import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
 import 
org.apache.paimon.append.dataevolution.DataEvolutionCompactionCommitPreparation;
+import org.apache.paimon.append.dataevolution.DataEvolutionRowIdReassigner;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.BinaryVector;
 import org.apache.paimon.data.BlobData;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.serializer.InternalRowSerializer;
 import org.apache.paimon.deletionvectors.BitmapDeletionVector;
 import org.apache.paimon.deletionvectors.DeletionVector;
 import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
@@ -42,6 +44,7 @@ import org.apache.paimon.io.DataIncrement;
 import org.apache.paimon.manifest.FileKind;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
@@ -64,6 +67,7 @@ import org.apache.paimon.utils.RangeHelper;
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.lang.reflect.Constructor;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -71,6 +75,7 @@ import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 
 import static 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
@@ -79,6 +84,7 @@ import static 
org.apache.paimon.types.VectorType.isVectorStoreFile;
 import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowable;
 
 /** Tests filename-anchored deletion vectors for data evolution tables. */
 public class DataEvolutionDeletionVectorTest extends DataEvolutionTestBase {
@@ -120,6 +126,194 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
         assertReadMatrix(getTableDefault(), "base");
     }
 
+    @Test
+    public void testRowIdReassignKeepsAndMergesDeletionVectors() throws 
Exception {
+        FileStoreTable table = 
createPartitionedReassignTable("reassign_dv_table", false);
+        writePartitionRows(table, "a", 0, 1, 2);
+        writePartitionRows(table, "b", 3, 4);
+        writePartitionRows(table, "a", 5, 6, 7);
+
+        BinaryRow partitionA = partition(table, "a");
+        BinaryRow partitionB = partition(table, "b");
+        commitDeletionVectors(
+                table,
+                partitionA,
+                Arrays.asList(new DvSpec(new Range(0, 2), 1), new DvSpec(new 
Range(5, 7), 6)));
+        commitDeletionVectors(
+                table, partitionB, Collections.singletonList(new DvSpec(new 
Range(3, 4), 3)));
+
+        long historicalSnapshotId = table.latestSnapshot().get().id();
+        List<String> anchorFilesBefore = 
liveDeletionVectorDataFileNames(table);
+        List<String> deletionVectorFilesBefore = 
liveDeletionVectorIndexFileNames(table);
+        assertThat(readPartitionedRowsWithRowIds(table))
+                .containsExactly("a|0|0", "a|2|2", "b|4|4", "a|5|5", "a|7|7");
+
+        DataEvolutionRowIdReassigner.Result result =
+                new 
DataEvolutionRowIdReassigner(table).reassign("test-reassign-dv");
+
+        assertThat(result.firstAssignedRowId).isEqualTo(8L);
+        assertThat(result.nextRowId).isEqualTo(14L);
+        assertThat(readPartitionedRowsWithRowIds(table))
+                .containsExactly("b|4|4", "a|0|8", "a|2|10", "a|5|11", 
"a|7|13");
+        
assertThat(liveDeletionVectorDataFileNames(table)).isEqualTo(anchorFilesBefore);
+        
assertThat(liveDeletionVectorIndexFileNames(table)).isEqualTo(deletionVectorFilesBefore);
+
+        commitDeletionVectors(
+                table,
+                partitionA,
+                Arrays.asList(new DvSpec(new Range(8, 10), 8), new DvSpec(new 
Range(11, 13), 13)));
+
+        assertThat(readPartitionedRowsWithRowIds(table))
+                .containsExactly("b|4|4", "a|2|10", "a|5|11");
+        
assertThat(liveDeletionVectorDataFileNames(table)).isEqualTo(anchorFilesBefore);
+
+        FileStoreTable historicalTable =
+                table.copy(
+                        Collections.singletonMap(
+                                CoreOptions.SCAN_SNAPSHOT_ID.key(),
+                                String.valueOf(historicalSnapshotId)));
+        assertThat(readPartitionedRowsWithRowIds(historicalTable))
+                .containsExactly("a|0|0", "a|2|2", "b|4|4", "a|5|5", "a|7|7");
+    }
+
+    @Test
+    public void testRowIdReassignKeepsPartialWriteAndBlobFileOffsets() throws 
Exception {
+        FileStoreTable table = 
createPartitionedReassignTable("reassign_dedicated_dv_table", true);
+        writeDedicatedPartitionRows(table, "a", 0, 1, 2, 3, 4);
+        writeDedicatedPartitionRows(table, "b", 5, 6);
+        writeDedicatedPartitionRows(table, "a", 7, 8, 9, 10, 11);
+        writePartialStrings(table, "a", 0L, 0, 1, 2, 3, 4);
+
+        BinaryRow partitionA = partition(table, "a");
+        commitDeletionVectors(
+                table,
+                partitionA,
+                Arrays.asList(new DvSpec(new Range(0, 4), 1), new DvSpec(new 
Range(7, 11), 8)));
+
+        Map<String, Range> relativeRangesBefore = relativeFileRanges(table, 
partitionA);
+        assertThat(relativeRangesBefore.entrySet())
+                .anyMatch(
+                        entry ->
+                                BlobFileFormat.isBlobFile(entry.getKey())
+                                        && entry.getValue().count() < 5
+                                        && entry.getValue().from > 0);
+        assertThat(normalFilesByRange(table).get(new Range(0, 4)))
+                .hasSize(2)
+                .anyMatch(
+                        file ->
+                                file.writeCols().contains("f1")
+                                        && !file.writeCols().contains("f0"));
+
+        new 
DataEvolutionRowIdReassigner(table).reassign("test-reassign-dedicated-dv");
+
+        assertThat(relativeFileRanges(table, 
partitionA)).isEqualTo(relativeRangesBefore);
+        assertThat(readPartitionedRowsWithRowIds(table))
+                .containsExactly(
+                        "b|5|base-5|5|5",
+                        "b|6|base-6|6|6",
+                        "a|0|updated-0|0|12",
+                        "a|2|updated-2|2|14",
+                        "a|3|updated-3|3|15",
+                        "a|4|updated-4|4|16",
+                        "a|7|base-7|7|17",
+                        "a|9|base-9|9|19",
+                        "a|10|base-10|10|20",
+                        "a|11|base-11|11|21");
+    }
+
+    @Test
+    public void testRowIdReassignAbortsAfterConcurrentDeletionVectorCommit() 
throws Exception {
+        FileStoreTable table =
+                createPartitionedReassignTable("reassign_concurrent_dv_table", 
false);
+        writePartitionRows(table, "a", 0, 1, 2);
+        writePartitionRows(table, "b", 3, 4);
+        writePartitionRows(table, "a", 5, 6, 7);
+        Snapshot before = table.latestSnapshot().get();
+        BinaryRow partitionA = partition(table, "a");
+        AtomicBoolean committed = new AtomicBoolean();
+
+        DataEvolutionRowIdReassigner reassigner =
+                reassignerWithBeforeCommit(
+                        table,
+                        () -> {
+                            if (committed.compareAndSet(false, true)) {
+                                try {
+                                    commitDeletionVectors(
+                                            table,
+                                            partitionA,
+                                            Collections.singletonList(
+                                                    new DvSpec(new Range(0, 
2), 1)));
+                                } catch (Exception e) {
+                                    throw new RuntimeException(e);
+                                }
+                            }
+                        });
+
+        assertThatThrownBy(() -> 
reassigner.reassign("test-reassign-concurrent-dv"))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining("OVERWRITE snapshot");
+
+        assertThat(committed).isTrue();
+        assertThat(table.latestSnapshot().get().id()).isEqualTo(before.id() + 
1);
+        assertThat(table.latestSnapshot().get().commitKind())
+                .isEqualTo(Snapshot.CommitKind.OVERWRITE);
+        assertThat(readPartitionedRowsWithRowIds(table))
+                .containsExactly("a|0|0", "a|2|2", "b|3|3", "b|4|4", "a|5|5", 
"a|6|6", "a|7|7");
+    }
+
+    @Test
+    public void testStaleCompactionIsRejectedAfterRowIdReassign() throws 
Exception {
+        FileStoreTable table = 
createPartitionedReassignTable("stale_compaction_dv_table", false);
+        writePartitionRows(table, "a", 0, 1, 2);
+        writePartitionRows(table, "b", 3, 4);
+        writePartitionRows(table, "a", 5, 6, 7);
+        writePartialStrings(table, "a", 0L, 0, 1, 2);
+
+        BinaryRow partitionA = partition(table, "a");
+        commitDeletionVectors(
+                table, partitionA, Collections.singletonList(new DvSpec(new 
Range(0, 2), 1)));
+
+        Snapshot compactSnapshot = table.latestSnapshot().get();
+        Map<String, String> dynamicOptions = new HashMap<>();
+        dynamicOptions.put(CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "2");
+        FileStoreTable compactTable = table.copy(dynamicOptions);
+        DataEvolutionCompactCoordinator coordinator =
+                new DataEvolutionCompactCoordinator(compactTable, false, 
false, compactSnapshot);
+        List<CommitMessage> staleCompactMessages = new ArrayList<>();
+        try {
+            while (true) {
+                for (DataEvolutionCompactTask task : coordinator.plan()) {
+                    staleCompactMessages.add(task.doCompact(compactTable, 
"test-stale-compact"));
+                }
+            }
+        } catch (EndOfScanException ignored) {
+        }
+        assertThat(staleCompactMessages).isNotEmpty();
+        staleCompactMessages.addAll(
+                new DataEvolutionCompactionCommitPreparation(compactTable, 
compactSnapshot)
+                        .prepare(staleCompactMessages));
+
+        DataEvolutionRowIdReassigner.Result result =
+                new 
DataEvolutionRowIdReassigner(table).reassign("test-before-stale-compact");
+        assertThat(result.firstAssignedRowId).isEqualTo(8L);
+        List<String> reassignedRows =
+                Arrays.asList("b|3|3", "b|4|4", "a|0|8", "a|2|10", "a|5|11", 
"a|6|12", "a|7|13");
+        
assertThat(readPartitionedRowsWithRowIds(table)).containsExactlyElementsOf(reassignedRows);
+        long reassignSnapshotId = table.latestSnapshot().get().id();
+
+        Throwable failure = catchThrowable(() -> commit(table, 
staleCompactMessages));
+
+        assertThat(readPartitionedRowsWithRowIds(table))
+                .as(
+                        "stale compaction must not change reassigned row IDs; 
commit failure: %s",
+                        failure)
+                .containsExactlyElementsOf(reassignedRows);
+        
assertThat(table.latestSnapshot().get().id()).isEqualTo(reassignSnapshotId);
+        assertThat(failure)
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining("Row ID existence conflict");
+    }
+
     @Test
     public void testReadAfterAddingColumnAndDeletionVectors() throws Exception 
{
         // DVs with adding new columns.
@@ -568,6 +762,144 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
                 new Range(10, 14));
     }
 
+    private FileStoreTable createPartitionedReassignTable(String tableName, 
boolean dedicated)
+            throws Exception {
+        Schema.Builder schemaBuilder = Schema.newBuilder();
+        schemaBuilder.column("pt", DataTypes.STRING());
+        schemaBuilder.column("f0", DataTypes.INT());
+        schemaBuilder.column("f1", DataTypes.STRING());
+        if (dedicated) {
+            schemaBuilder.column("f2", DataTypes.BLOB());
+            schemaBuilder.option(CoreOptions.BLOB_TARGET_FILE_SIZE.key(), "1 
b");
+            schemaBuilder.option(CoreOptions.FILE_COMPRESSION.key(), "none");
+        }
+        schemaBuilder.partitionKeys("pt");
+        schemaBuilder.option(CoreOptions.TARGET_FILE_SIZE.key(), "128 MB");
+        schemaBuilder.option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        schemaBuilder.option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        schemaBuilder.option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true");
+        catalog.createTable(identifier(tableName), schemaBuilder.build(), 
false);
+        return getTable(identifier(tableName));
+    }
+
+    private void writePartitionRows(FileStoreTable table, String partition, 
int... values)
+            throws Exception {
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite();
+                BatchTableCommit commit = builder.newCommit()) {
+            for (int value : values) {
+                write.write(
+                        GenericRow.of(
+                                BinaryString.fromString(partition),
+                                value,
+                                BinaryString.fromString("base-" + value)));
+            }
+            commit.commit(write.prepareCommit());
+        }
+    }
+
+    private void writeDedicatedPartitionRows(FileStoreTable table, String 
partition, int... values)
+            throws Exception {
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite();
+                BatchTableCommit commit = builder.newCommit()) {
+            for (int value : values) {
+                write.write(
+                        GenericRow.of(
+                                BinaryString.fromString(partition),
+                                value,
+                                BinaryString.fromString("base-" + value),
+                                new BlobData(new byte[] {(byte) value})));
+            }
+            commit.commit(write.prepareCommit());
+        }
+    }
+
+    private void writePartialStrings(
+            FileStoreTable table, String partition, long firstRowId, int... 
values)
+            throws Exception {
+        RowType writeType = table.rowType().project(Arrays.asList("pt", "f1"));
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = 
builder.newWrite().withWriteType(writeType);
+                BatchTableCommit commit = builder.newCommit()) {
+            for (int value : values) {
+                write.write(
+                        GenericRow.of(
+                                BinaryString.fromString(partition),
+                                BinaryString.fromString("updated-" + value)));
+            }
+            List<CommitMessage> messages = write.prepareCommit();
+            setFirstRowId(messages, firstRowId);
+            commit.commit(messages);
+        }
+    }
+
+    private static BinaryRow partition(FileStoreTable table, String value) {
+        return new InternalRowSerializer(table.schema().logicalPartitionType())
+                .toBinaryRow(GenericRow.of(BinaryString.fromString(value)));
+    }
+
+    private static List<String> readPartitionedRowsWithRowIds(FileStoreTable 
table)
+            throws IOException {
+        RowType readType = SpecialFields.rowTypeWithRowId(table.rowType());
+        int rowIdIndex = table.rowType().getFieldCount();
+        boolean withBlob = rowIdIndex == 4;
+        ReadBuilder readBuilder = 
table.newReadBuilder().withReadType(readType);
+        List<String> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+            reader.forEachRemaining(
+                    row -> {
+                        String value = row.getString(0) + "|" + row.getInt(1);
+                        if (withBlob) {
+                            value +=
+                                    "|"
+                                            + row.getString(2)
+                                            + "|"
+                                            + (row.getBlob(3).toData()[0] & 
0xFF);
+                        }
+                        rows.add(value + "|" + row.getLong(rowIdIndex));
+                    });
+        }
+        rows.sort(
+                Comparator.comparingLong(
+                        row -> 
Long.parseLong(row.substring(row.lastIndexOf('|') + 1))));
+        return rows;
+    }
+
+    private static Map<String, Range> relativeFileRanges(
+            FileStoreTable table, BinaryRow partition) {
+        List<DataFileMeta> dataFiles = currentDataFiles(table, partition);
+        RangeHelper<DataFileMeta> rangeHelper = new 
RangeHelper<>(DataFileMeta::nonNullRowIdRange);
+        Map<String, Range> result = new HashMap<>();
+        for (List<DataFileMeta> group : 
rangeHelper.mergeOverlappingRanges(dataFiles)) {
+            Range anchorRange = retrieveAnchorFile(group, file -> 
file).nonNullRowIdRange();
+            for (DataFileMeta file : group) {
+                Range range = file.nonNullRowIdRange();
+                result.put(
+                        file.fileName(),
+                        new Range(range.from - anchorRange.from, range.to - 
anchorRange.from));
+            }
+        }
+        return result;
+    }
+
+    private static List<DataFileMeta> currentDataFiles(FileStoreTable table, 
BinaryRow partition) {
+        return table.store().newScan().plan().files().stream()
+                .filter(entry -> entry.partition().equals(partition))
+                .map(ManifestEntry::file)
+                .collect(Collectors.toList());
+    }
+
+    private static DataEvolutionRowIdReassigner reassignerWithBeforeCommit(
+            FileStoreTable table, Runnable beforeCommit) throws Exception {
+        Constructor<DataEvolutionRowIdReassigner> constructor =
+                DataEvolutionRowIdReassigner.class.getDeclaredConstructor(
+                        FileStoreTable.class, PartitionPredicate.class, 
Runnable.class);
+        constructor.setAccessible(true);
+        return constructor.newInstance(table, null, beforeCommit);
+    }
+
     @Override
     protected Schema schemaDefault() {
         Schema.Builder schemaBuilder = Schema.newBuilder();
@@ -671,12 +1003,18 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
 
     private void commitDeletionVectors(FileStoreTable table, List<DvSpec> 
deletionVectorSpecs)
             throws Exception {
+        commitDeletionVectors(table, BinaryRow.EMPTY_ROW, deletionVectorSpecs);
+    }
+
+    private void commitDeletionVectors(
+            FileStoreTable table, BinaryRow partition, List<DvSpec> 
deletionVectorSpecs)
+            throws Exception {
         BaseAppendDeleteFileMaintainer maintainer =
                 BaseAppendDeleteFileMaintainer.forUnawareAppend(
                         table.store().newIndexFileHandler(),
                         table.latestSnapshot().get(),
-                        BinaryRow.EMPTY_ROW);
-        Map<Range, String> anchorFiles = anchorFilesByRange(table);
+                        partition);
+        Map<Range, String> anchorFiles = anchorFilesByRange(table, partition);
 
         for (DvSpec spec : deletionVectorSpecs) {
             DeletionVector deletionVector = new BitmapDeletionVector();
@@ -700,7 +1038,7 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
                 table,
                 Collections.singletonList(
                         new CommitMessageImpl(
-                                BinaryRow.EMPTY_ROW,
+                                partition,
                                 UNAWARE_BUCKET,
                                 null,
                                 new DataIncrement(
@@ -720,10 +1058,11 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
     }
 
     private Map<Range, String> anchorFilesByRange(FileStoreTable table) {
-        List<DataFileMeta> dataFiles =
-                table.store().newScan().plan().files().stream()
-                        .map(ManifestEntry::file)
-                        .collect(Collectors.toList());
+        return anchorFilesByRange(table, BinaryRow.EMPTY_ROW);
+    }
+
+    private Map<Range, String> anchorFilesByRange(FileStoreTable table, 
BinaryRow partition) {
+        List<DataFileMeta> dataFiles = currentDataFiles(table, partition);
         RangeHelper<DataFileMeta> rangeHelper = new 
RangeHelper<>(DataFileMeta::nonNullRowIdRange);
         Map<Range, String> result = new HashMap<>();
         for (List<DataFileMeta> group : 
rangeHelper.mergeOverlappingRanges(dataFiles)) {
@@ -917,6 +1256,18 @@ public class DataEvolutionDeletionVectorTest extends 
DataEvolutionTestBase {
         return result;
     }
 
+    private static List<String> 
liveDeletionVectorIndexFileNames(FileStoreTable table) {
+        List<String> result = new ArrayList<>();
+        for (IndexManifestEntry entry :
+                table.store()
+                        .newIndexFileHandler()
+                        .scan(table.latestSnapshot().get(), 
DELETION_VECTORS_INDEX)) {
+            result.add(entry.indexFile().fileName());
+        }
+        Collections.sort(result);
+        return result;
+    }
+
     private static List<String> readRows(ReadBuilder readBuilder, 
TableScan.Plan plan)
             throws IOException {
         List<String> rows = new ArrayList<>();


Reply via email to