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 ff2f1cec13 [core] Reuse compaction results across row ID reassignment
(#10432)
ff2f1cec13 is described below
commit ff2f1cec131542965c57e6808b8d5e8e95be8125
Author: YeJunHao <[email protected]>
AuthorDate: Fri Oct 9 13:56:18 2026 +0800
[core] Reuse compaction results across row ID reassignment (#10432)
---
.../dataevolution/SerializationAssignment.java | 26 +
.../paimon/manifest/IndexManifestFileHandler.java | 19 +-
.../paimon/operation/FileStoreCommitImpl.java | 77 +-
.../operation/commit/CommitChangesProvider.java | 7 +
.../commit/ReassignCompactChangesProvider.java | 173 +++++
.../paimon/operation/commit/StrictModeChecker.java | 8 +-
.../dataevolution/CompactReassignReuseTest.java | 797 +++++++++++++++++++++
.../manifest/IndexManifestFileHandlerTest.java | 103 +++
.../paimon/operation/FileStoreCommitTest.java | 8 +-
9 files changed, 1195 insertions(+), 23 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/SerializationAssignment.java
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/SerializationAssignment.java
index 667129e472..02d2b1e8da 100644
---
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/SerializationAssignment.java
+++
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/SerializationAssignment.java
@@ -26,6 +26,7 @@ import org.apache.paimon.io.DataInputDeserializer;
import org.apache.paimon.io.DataOutputViewStreamWrapper;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Range;
import javax.annotation.Nullable;
@@ -44,6 +45,7 @@ import java.util.zip.CheckedOutputStream;
import static org.apache.paimon.utils.IOUtils.readFully;
import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkState;
import static org.apache.paimon.utils.SerializationUtils.deserializeBinaryRow;
import static org.apache.paimon.utils.SerializationUtils.readCount;
import static org.apache.paimon.utils.SerializationUtils.serializeBinaryRow;
@@ -98,6 +100,30 @@ public final class SerializationAssignment {
return nextRowId;
}
+ /** Maps a complete file range, preserving every row's offset within the
file. */
+ public Range mapRowRange(BinaryRow partition, Range range) {
+ RowRangeMappingIndex mapping = rowIdMappings.get(partition);
+ if (mapping == null || !mapping.overlaps(range)) {
+ return range;
+ }
+ Range mapped =
+ mapping.map(range)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "Cannot reuse compaction
across reassignment: range "
+ + range
+ + " is only partially
or non-contiguously mapped."));
+ checkState(
+ mapped.from >= firstAssignedRowId
+ && mapped.to < nextRowId
+ && mapped.count() == range.count(),
+ "Invalid reassignment of compaction range %s to %s.",
+ range,
+ mapped);
+ return mapped;
+ }
+
/** Returns a plan only for the snapshot that committed it, ignoring
inherited properties. */
@Nullable
public static String planFile(Snapshot snapshot) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
index c313a8ebcb..13d3feb6b2 100644
---
a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
+++
b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
@@ -149,10 +149,12 @@ public class IndexManifestFileHandler {
}
indexEntries.put(fileName, entry);
} else {
+ IndexManifestEntry previous = indexEntries.get(fileName);
checkState(
- indexEntries.containsKey(fileName),
+ previous != null,
"Trying to delete file %s which is not exists.",
fileName);
+ checkDeletedIndexFile(previous, entry);
if (dvRanges != null) {
for (String dataFile : dvRanges.keySet()) {
checkState(
@@ -221,10 +223,12 @@ public class IndexManifestFileHandler {
.collect(Collectors.toList());
for (IndexManifestEntry entry : removed) {
String fileName = entry.indexFile().fileName();
+ IndexManifestEntry previous = indexEntries.get(fileName);
checkState(
- indexEntries.containsKey(fileName),
+ previous != null,
"Trying to delete global index file %s which does not
exist.",
fileName);
+ checkDeletedIndexFile(previous, entry);
indexEntries.remove(fileName);
}
validateRetainedIndexFiles(indexEntries.values(), added);
@@ -280,6 +284,17 @@ public class IndexManifestFileHandler {
}
}
+ private static void checkDeletedIndexFile(
+ IndexManifestEntry previous, IndexManifestEntry deleted) {
+ // File identity alone does not detect metadata-only row-id
reassignment.
+ checkState(
+ previous.partition().equals(deleted.partition())
+ && previous.bucket() == deleted.bucket()
+ && previous.indexFile().equals(deleted.indexFile()),
+ "Index file %s metadata does not match the current entry.",
+ deleted.indexFile().fileName());
+ }
+
private static BucketIdentifier identifier(IndexManifestEntry
indexManifestEntry) {
return new BucketIdentifier(
indexManifestEntry.partition(),
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 e518c8f0bc..18cc7a8edf 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
@@ -50,6 +50,7 @@ import org.apache.paimon.operation.commit.CommitRollback;
import org.apache.paimon.operation.commit.CommitScanner;
import org.apache.paimon.operation.commit.ConflictDetection;
import org.apache.paimon.operation.commit.ManifestEntryChanges;
+import org.apache.paimon.operation.commit.ReassignCompactChangesProvider;
import org.apache.paimon.operation.commit.RetryCommitResult;
import
org.apache.paimon.operation.commit.RetryCommitResult.CommitFailRetryResult;
import
org.apache.paimon.operation.commit.RetryCommitResult.ManifestMergeResult;
@@ -387,7 +388,12 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
|| !changes.compactIndexFiles.isEmpty()) {
attempts +=
tryCommit(
- compactChangesProvider(changes,
materializedBuckets),
+ compactChangesProvider(
+ new CommitChanges(
+ changes.compactTableFiles,
+ changes.compactChangelog,
+ changes.compactIndexFiles),
+ materializedBuckets),
committable.identifier(),
committable.watermark(),
committable.properties(),
@@ -571,10 +577,12 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
if (withCompact) {
attempts +=
tryCommit(
- CommitChangesProvider.provider(
- changes.compactTableFiles,
- emptyList(),
- changes.compactIndexFiles),
+ compactChangesProvider(
+ new CommitChanges(
+ changes.compactTableFiles,
+ emptyList(),
+ changes.compactIndexFiles),
+ Collections.emptySet()),
committable.identifier(),
committable.watermark(),
committable.properties(),
@@ -827,15 +835,23 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
@VisibleForTesting
CommitChangesProvider compactChangesProvider(
- ManifestEntryChanges changes, Set<Pair<BinaryRow, Integer>>
materializedBuckets) {
+ CommitChanges changes, Set<Pair<BinaryRow, Integer>>
materializedBuckets) {
if (materializedBuckets.isEmpty()) {
+ Long lastSafeSnapshot =
options.commitLastSafeSnapshot().orElse(null);
+ if (options.dataEvolutionEnabled()
+ &&
!conflictDetection.shouldCheckRowIdFromSnapshot(CommitKind.COMPACT)
+ && lastSafeSnapshot != null
+ && lastSafeSnapshot >= 0) {
+ return new ReassignCompactChangesProvider(
+ fileIO, pathFactory, snapshotManager,
lastSafeSnapshot, changes);
+ }
return CommitChangesProvider.provider(
- changes.compactTableFiles, changes.compactChangelog,
changes.compactIndexFiles);
+ changes.tableFiles, changes.changelogFiles,
changes.indexFiles);
}
return latestSnapshot -> {
List<IndexManifestEntry> indexFiles =
- changes.compactIndexFiles.stream()
+ changes.indexFiles.stream()
// Replace global-index deletions prepared against
an older snapshot.
.filter(
entry ->
@@ -861,8 +877,7 @@ public class FileStoreCommitImpl implements FileStoreCommit
{
}
}
- return new CommitChanges(
- changes.compactTableFiles, changes.compactChangelog,
indexFiles);
+ return new CommitChanges(changes.tableFiles,
changes.changelogFiles, indexFiles);
};
}
@@ -880,13 +895,10 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
long startMillis = System.currentTimeMillis();
while (true) {
Snapshot latestSnapshot = snapshotManager.latestSnapshot();
- CommitChanges changes = changesProvider.provide(latestSnapshot);
CommitResult result =
tryCommitOnce(
retryResult,
- changes.tableFiles,
- changes.changelogFiles,
- changes.indexFiles,
+ changesProvider,
identifier,
watermark,
properties,
@@ -1016,10 +1028,34 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
@Nullable Snapshot latestSnapshot,
boolean detectConflicts,
@Nullable String newStatsFileName) {
+ return tryCommitOnce(
+ retryResult,
+ CommitChangesProvider.provider(deltaFiles, changelogFiles,
indexFiles),
+ identifier,
+ watermark,
+ properties,
+ commitKind,
+ allowRollback,
+ latestSnapshot,
+ detectConflicts,
+ newStatsFileName);
+ }
+
+ private CommitResult tryCommitOnce(
+ @Nullable RetryCommitResult retryResult,
+ CommitChangesProvider changesProvider,
+ long identifier,
+ @Nullable Long watermark,
+ Map<String, String> properties,
+ CommitKind commitKind,
+ boolean allowRollback,
+ @Nullable Snapshot latestSnapshot,
+ boolean detectConflicts,
+ @Nullable String newStatsFileName) {
long startMillis = System.currentTimeMillis();
- // Check if the commit has been completed. At this point, there will
be no more repeated
- // commits and just return success
+ // Resolve an uncertain successful commit before preparing changes,
which may refer
+ // to inputs removed by that successful commit.
boolean hasOverwriteSinceLastAttempt = false;
if (retryResult instanceof CommitFailRetryResult && latestSnapshot !=
null) {
CommitFailRetryResult commitFailRetry = (CommitFailRetryResult)
retryResult;
@@ -1043,6 +1079,12 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
}
}
+ CommitChanges changes = changesProvider.provide(latestSnapshot);
+ List<ManifestEntry> deltaFiles = changes.tableFiles;
+ List<ManifestEntry> changelogFiles = changes.changelogFiles;
+ List<IndexManifestEntry> indexFiles = changes.indexFiles;
+ Set<Long> rebasedReassignments =
changesProvider.rebasedReassignments();
+
long newSnapshotId = Snapshot.FIRST_SNAPSHOT_ID;
long firstRowIdStart = 0;
if (latestSnapshot != null) {
@@ -1060,7 +1102,8 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
List<BinaryRow> changedPartitions = null;
if (strictModeChecker != null) {
changedPartitions = changedPartitions(deltaFiles, indexFiles);
- strictModeChecker.check(newSnapshotId, commitKind,
changedPartitions);
+ strictModeChecker.check(
+ newSnapshotId, commitKind, changedPartitions,
rebasedReassignments);
strictModeChecker.update(newSnapshotId - 1);
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/CommitChangesProvider.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/CommitChangesProvider.java
index 682198d4be..7759225d3e 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/CommitChangesProvider.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/CommitChangesProvider.java
@@ -24,7 +24,9 @@ import org.apache.paimon.manifest.ManifestEntry;
import javax.annotation.Nullable;
+import java.util.Collections;
import java.util.List;
+import java.util.Set;
/** Provider to provide {@link CommitChanges}. */
@FunctionalInterface
@@ -32,6 +34,11 @@ public interface CommitChangesProvider {
CommitChanges provide(@Nullable Snapshot latestSnapshot);
+ /** Reassignments already validated and applied to this provider's compact
results. */
+ default Set<Long> rebasedReassignments() {
+ return Collections.emptySet();
+ }
+
static CommitChangesProvider provider(
List<ManifestEntry> tableFiles,
List<ManifestEntry> changelogFiles,
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/ReassignCompactChangesProvider.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/ReassignCompactChangesProvider.java
new file mode 100644
index 0000000000..88d3dcf906
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/ReassignCompactChangesProvider.java
@@ -0,0 +1,173 @@
+/*
+ * 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.append.dataevolution.SerializationAssignment;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.SnapshotManager;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkState;
+
+/** Reuses immutable compact outputs after verified, metadata-only row-id
reassignments. */
+public final class ReassignCompactChangesProvider implements
CommitChangesProvider {
+
+ private final FileIO fileIO;
+ private final FileStorePathFactory paths;
+ private final SnapshotManager snapshots;
+ private final Set<Long> reassignments = new HashSet<>();
+ private long checkedSnapshot;
+ private CommitChanges changes;
+
+ public ReassignCompactChangesProvider(
+ FileIO fileIO,
+ FileStorePathFactory paths,
+ SnapshotManager snapshots,
+ long scanSnapshotId,
+ CommitChanges changes) {
+ this.fileIO = fileIO;
+ this.paths = paths;
+ this.snapshots = snapshots;
+ this.checkedSnapshot = scanSnapshotId;
+ this.changes = changes;
+ }
+
+ @Override
+ public CommitChanges provide(@Nullable Snapshot latest) {
+ checkState(
+ latest != null && latest.id() >= checkedSnapshot,
+ "Compaction planning snapshot %s is ahead of the current
table.",
+ checkedSnapshot);
+ // Like StrictModeChecker, only history after the boundary is needed.
The boundary
+ // snapshot itself may already have expired.
+ for (long id = checkedSnapshot + 1; id <= latest.id(); id++) {
+ checkState(
+ snapshots.snapshotExists(id),
+ "Cannot reuse compaction with missing snapshot %s.",
+ id);
+ Snapshot snapshot = snapshots.snapshot(id);
+ String plan =
+ snapshot.commitKind() == Snapshot.CommitKind.OVERWRITE
+ ? SerializationAssignment.planFile(snapshot)
+ : null;
+ if (plan != null) {
+ SerializationAssignment assignment;
+ try {
+ assignment = SerializationAssignment.readPlan(fileIO,
paths, plan);
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Cannot read compaction reassignment plan " +
plan, e);
+ }
+ checkState(
+ assignment.snapshotId() == id
+ && Objects.equals(snapshot.nextRowId(),
assignment.nextRowId()),
+ "Reassignment plan does not match snapshot %s.",
+ id);
+ changes = mapChanges(assignment);
+ reassignments.add(id);
+ }
+ checkedSnapshot = id;
+ }
+ return changes;
+ }
+
+ @Override
+ public Set<Long> rebasedReassignments() {
+ return Collections.unmodifiableSet(reassignments);
+ }
+
+ private CommitChanges mapChanges(SerializationAssignment assignment) {
+ checkState(
+ changes.changelogFiles.isEmpty(),
+ "Cannot reuse compaction across reassignment with changelog
files.");
+ List<ManifestEntry> files = new ArrayList<>(changes.tableFiles.size());
+ for (ManifestEntry entry : changes.tableFiles) {
+ DataFileMeta file = entry.file();
+ checkState(
+ file.firstRowId() != null,
+ "Cannot reuse compaction across reassignment: file %s has
no first row ID.",
+ file.fileName());
+ checkState(
+ file.writeCols() == null
+ ||
!file.writeCols().contains(SpecialFields.ROW_ID.name()),
+ "Cannot reuse compaction across reassignment: file %s
physically stores %s.",
+ file.fileName(),
+ SpecialFields.ROW_ID.name());
+ Range range = assignment.mapRowRange(entry.partition(),
file.nonNullRowIdRange());
+ files.add(entry.assignFirstRowId(range.from));
+ }
+ List<IndexManifestEntry> indexes = new
ArrayList<>(changes.indexFiles.size());
+ for (IndexManifestEntry entry : changes.indexFiles) {
+ IndexFileMeta file = entry.indexFile();
+ GlobalIndexMeta meta = file.globalIndexMeta();
+ if (meta == null) {
+ checkState(
+ file.dvRanges() != null,
+ "Cannot reuse compaction across reassignment: index
file %s has unsupported index type %s.",
+ file.fileName(),
+ file.indexType());
+ // Deletion vectors use file-relative positions, not global
row IDs.
+ indexes.add(entry);
+ continue;
+ }
+ Range range = assignment.mapRowRange(entry.partition(),
meta.rowRange());
+ GlobalIndexMeta mapped =
+ new GlobalIndexMeta(
+ range.from,
+ range.to,
+ meta.indexFieldId(),
+ meta.extraFieldIds(),
+ meta.indexMeta(),
+ meta.sourceMeta());
+ indexes.add(
+ new IndexManifestEntry(
+ entry.kind(),
+ entry.partition(),
+ entry.bucket(),
+ new IndexFileMeta(
+ file.indexType(),
+ file.fileName(),
+ file.fileSize(),
+ file.rowCount(),
+ file.dvRanges(),
+ file.externalPath(),
+ mapped)));
+ }
+ return new CommitChanges(files, changes.changelogFiles, indexes);
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/StrictModeChecker.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/StrictModeChecker.java
index 69fcef0bc9..092c2d331b 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/StrictModeChecker.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/StrictModeChecker.java
@@ -65,9 +65,15 @@ public class StrictModeChecker {
}
public void check(
- long newSnapshotId, CommitKind newCommitKind, List<BinaryRow>
newChangedPartitions) {
+ long newSnapshotId,
+ CommitKind newCommitKind,
+ List<BinaryRow> newChangedPartitions,
+ Set<Long> rebasedReassignments) {
Set<BinaryRow> newPartitions = new HashSet<>(newChangedPartitions);
for (long id = lastSafeSnapshot + 1; id < newSnapshotId; id++) {
+ if (newCommitKind == CommitKind.COMPACT &&
rebasedReassignments.contains(id)) {
+ continue;
+ }
Snapshot snapshot = snapshotManager.snapshot(id);
if (snapshot.commitUser().equals(commitUser)) {
continue;
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/CompactReassignReuseTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/CompactReassignReuseTest.java
new file mode 100644
index 0000000000..27ecba030a
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/CompactReassignReuseTest.java
@@ -0,0 +1,797 @@
+/*
+ * 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.append.dataevolution;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.RenamingSnapshotCommit;
+import org.apache.paimon.catalog.SnapshotCommit;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.BlobData;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.DataEvolutionGlobalIndexScanner;
+import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexScanner;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexTestUtils;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+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.operation.Lock;
+import org.apache.paimon.operation.commit.CommitChanges;
+import org.apache.paimon.operation.commit.ReassignCompactChangesProvider;
+import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.CatalogEnvironment;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.table.TableTestBase;
+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.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.CloseableIterator;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.SnapshotManager;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Real compact outputs must survive only metadata-only reassignment
conflicts. */
+public class CompactReassignReuseTest extends TableTestBase {
+
+ private final Map<CommitMessage, Long> lastSafeSnapshots = new
IdentityHashMap<>();
+
+ @Override
+ protected Schema schemaDefault() {
+ return Schema.newBuilder()
+ .column("pt", DataTypes.STRING())
+ .column("id", DataTypes.INT())
+ .column("image", DataTypes.BLOB())
+ .partitionKeys("pt")
+ .option("bucket", "-1")
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("manifest.sidecar.enabled", "true")
+ .option("snapshot.num-retained.min", "100")
+ .build();
+ }
+
+ private FileStoreTable prepare() throws Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+ write(table, "a", 0, false);
+ write(table, "a", 1, false);
+ write(table, "b", 2, false);
+ write(table, "a", 3, false);
+ return table;
+ }
+
+ private void write(FileStoreTable table, String pt, int id, boolean
overwrite)
+ throws Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ if (overwrite) {
+ builder.withOverwrite(Collections.singletonMap("pt", pt));
+ }
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ write.write(
+ GenericRow.of(
+ BinaryString.fromString(pt),
+ id,
+ new BlobData(("image-" +
id).getBytes(StandardCharsets.UTF_8))));
+ commit.commit(write.prepareCommit());
+ }
+ }
+
+ private CommitMessageImpl compact(FileStoreTable table, boolean blob)
throws Exception {
+ List<ManifestEntry> entries =
+ table.store().newScan().plan().files().stream()
+ .filter(e ->
e.partition().getString(0).toString().equals("a"))
+ .filter(e -> isBlobFile(e.file().fileName()) == blob)
+ .filter(e -> e.file().nonNullFirstRowId() < 2)
+ .sorted(Comparator.comparingLong(e ->
e.file().nonNullFirstRowId()))
+ .collect(Collectors.toList());
+ assertThat(entries).hasSize(2);
+ List<DataFileMeta> files =
+
entries.stream().map(ManifestEntry::file).collect(Collectors.toList());
+ DataEvolutionCompactTask task =
+ blob
+ ? new
DataEvolutionBlobCompactTask(entries.get(0).partition(), files)
+ : new
DataEvolutionNormalCompactTask(entries.get(0).partition(), files);
+ CommitMessageImpl message = (CommitMessageImpl) task.doCompact(table,
"compact-worker");
+ assertThat(message.checkFromSnapshot()).isNull();
+ lastSafeSnapshots.put(message,
table.snapshotManager().latestSnapshotId());
+ return message;
+ }
+
+ private CommitMessageImpl compactIndex(FileStoreTable table) throws
Exception {
+ List<CommitMessage> builds = new ArrayList<>();
+ for (DataSplit split :
+ GlobalIndexBuilderUtils.splitByContiguousRowRange(
+ new SortedGlobalIndexScanner(table, "btree")
+
.withIndexFields(Collections.singletonList("id"))
+ .scan()
+ .get()
+ .entries())) {
+ builds.addAll(SortedGlobalIndexTestUtils.buildIndex(table,
"btree", "id", split, 4));
+ }
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(builds);
+ }
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ List<IndexManifestEntry> inputs =
+
table.store().indexManifestFileFactory().create().read(snapshot.indexManifest())
+ .stream()
+ .filter(e ->
e.partition().getString(0).toString().equals("a"))
+ .filter(
+ e ->
+ e.indexFile()
+ .globalIndexMeta()
+ .rowRange()
+ .equals(new Range(0, 1)))
+ .collect(Collectors.toList());
+ assertThat(inputs).isNotEmpty();
+ DataSplit split =
+ GlobalIndexBuilderUtils.splitByContiguousRowRange(
+ new SortedGlobalIndexScanner(table, "btree")
+
.withIndexFields(Collections.singletonList("id"))
+ .scan()
+ .get()
+ .entries())
+ .stream()
+ .filter(s ->
s.partition().equals(inputs.get(0).partition()))
+ .filter(
+ s ->
+ s.dataFiles().stream()
+ .allMatch(f ->
f.nonNullFirstRowId() < 2))
+ .findFirst()
+ .get();
+ List<IndexFileMeta> outputs = new ArrayList<>();
+ for (CommitMessage built :
+ SortedGlobalIndexTestUtils.buildIndex(table, "btree", "id",
split, snapshot.id())) {
+ outputs.addAll(((CommitMessageImpl)
built).newFilesIncrement().newIndexFiles());
+ }
+ CommitMessageImpl message =
+ new CommitMessageImpl(
+ inputs.get(0).partition(),
+ 0,
+ null,
+ DataIncrement.emptyIncrement(),
+ new CompactIncrement(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ outputs,
+ inputs.stream()
+ .map(IndexManifestEntry::indexFile)
+ .collect(Collectors.toList())));
+ lastSafeSnapshots.put(message, snapshot.id());
+ return message;
+ }
+
+ private void commit(FileStoreTable table, CommitMessage message, boolean
strict)
+ throws Exception {
+ commit(table, message, strict,
CoreOptions.COMMIT_LAST_SAFE_SNAPSHOT.key());
+ }
+
+ private void commit(FileStoreTable table, CommitMessage message, boolean
strict, String key)
+ throws Exception {
+ Map<String, String> options = new HashMap<>();
+ options.put(key, lastSafeSnapshots.get(message).toString());
+ options.put(CoreOptions.COMMIT_STRICT_MODE_ENABLED.key(),
Boolean.toString(strict));
+ try (BatchTableCommit commit =
table.copy(options).newBatchWriteBuilder().newCommit()) {
+ commit.commit(Collections.singletonList(message));
+ }
+ }
+
+ private void checkRows(FileStoreTable table, int... expected) throws
Exception {
+ ReadBuilder builder = table.newReadBuilder();
+ TreeSet<Integer> actual = new TreeSet<>();
+ try (RecordReader<InternalRow> reader =
+
builder.newRead().createReader(builder.newScan().plan());
+ CloseableIterator<InternalRow> rows =
reader.toCloseableIterator()) {
+ while (rows.hasNext()) {
+ InternalRow row = rows.next();
+ int id = row.getInt(1);
+ assertThat(actual.add(id)).isTrue();
+ assertThat(row.getBlob(2).toData())
+ .isEqualTo(("image-" +
id).getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ assertThat(actual)
+ .containsExactlyElementsOf(
+
Arrays.stream(expected).boxed().collect(Collectors.toList()));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testReuseNormalAndBlobAcrossRepeatedReassignments(boolean blob)
throws Exception {
+ FileStoreTable table = prepare();
+ if (blob) {
+ // A merged BLOB must stay within one normal-file range.
+ commit(table, compact(table, false), false);
+ }
+ CommitMessageImpl message = compact(table, blob);
+ List<String> outputNames =
+ message.compactIncrement().compactAfter().stream()
+ .map(DataFileMeta::fileName)
+ .collect(Collectors.toList());
+ assertThat(new
DataEvolutionRowIdReassigner(table).reassign().reassigned).isTrue();
+ write(table, "b", 4, false);
+ write(table, "a", 5, false);
+ assertThat(new
DataEvolutionRowIdReassigner(table).reassign().reassigned).isTrue();
+ commit(table, message, true);
+ assertThat(table.snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+ List<DataFileMeta> outputs =
+ table.store().newScan().plan().files().stream()
+ .map(ManifestEntry::file)
+ .filter(f -> outputNames.contains(f.fileName()))
+ .collect(Collectors.toList());
+ assertThat(outputs).hasSize(outputNames.size());
+ assertThat(outputs)
+ .allSatisfy(f ->
assertThat(f.nonNullFirstRowId()).isGreaterThanOrEqualTo(9));
+ // The original worker result is reusable, not mutated in-place on
each retry.
+
assertThat(message.compactIncrement().compactAfter().get(0).nonNullFirstRowId()).isZero();
+ checkRows(table, 0, 1, 2, 3, 4, 5);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testIndexReplacementKeepsPhysicalFilesAndPointLookup(boolean
loseSuccessfulResponse)
+ throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compactIndex(table);
+ List<IndexFileMeta> outputs =
message.compactIncrement().newIndexFiles();
+ assertThat(new
DataEvolutionRowIdReassigner(table).reassign().reassigned).isTrue();
+ long reassignedSnapshot = table.snapshotManager().latestSnapshotId();
+ AtomicInteger attempts = new AtomicInteger();
+ CatalogEnvironment environment =
+ new CatalogEnvironment(null, null, null, null, null, null,
false, false) {
+ @Override
+ public SnapshotCommit snapshotCommit(SnapshotManager
manager) {
+ SnapshotCommit delegate = new
RenamingSnapshotCommit(manager, Lock.empty());
+ return new SnapshotCommit() {
+ @Override
+ public boolean commit(
+ String baseUuid,
+ Snapshot snapshot,
+ String branch,
+ List<PartitionStatistics> statistics)
+ throws Exception {
+ boolean committed =
+ delegate.commit(baseUuid, snapshot,
branch, statistics);
+ if (snapshot.commitKind() ==
Snapshot.CommitKind.COMPACT) {
+ attempts.incrementAndGet();
+ return committed &&
!loseSuccessfulResponse;
+ }
+ return committed;
+ }
+
+ @Override
+ public void close() throws Exception {
+ delegate.close();
+ }
+ };
+ }
+ };
+ FileStoreTable committingTable =
+ FileStoreTableFactory.create(
+ table.fileIO(), table.location(), table.schema(),
environment);
+ commit(committingTable, message, false);
+ assertThat(attempts).hasValue(1);
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(reassignedSnapshot
+ 1);
+ List<IndexManifestEntry> current =
+ table.store()
+ .indexManifestFileFactory()
+ .create()
+
.read(table.snapshotManager().latestSnapshot().indexManifest());
+ assertThat(current.stream().map(e -> e.indexFile().fileName()))
+ .containsAll(
+
outputs.stream().map(IndexFileMeta::fileName).collect(Collectors.toList()));
+ Predicate predicate = new PredicateBuilder(table.rowType()).equal(1,
1);
+ try (DataEvolutionGlobalIndexScanner scanner =
+ DataEvolutionGlobalIndexScanner.create(table, null,
predicate).get()) {
+ GlobalIndexResult ids = scanner.scan(predicate).get();
+ assertThat(ids.results().getLongCardinality()).isEqualTo(1);
+ assertThat(ids.results().contains(5L)).isTrue();
+ }
+ checkRows(table, 0, 1, 2, 3);
+ }
+
+ @ParameterizedTest
+ @CsvSource({"false,false", "false,true", "true,false", "true,true"})
+ void testLostIndexCommitResponseBeforeDeletionAndReassignment(
+ boolean reassignBeforeCompact, boolean throwAfterSuccess) throws
Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compactIndex(table);
+ if (reassignBeforeCompact) {
+ assertThat(new
DataEvolutionRowIdReassigner(table).reassign().reassigned).isTrue();
+ }
+ List<String> outputNames =
+ message.compactIncrement().newIndexFiles().stream()
+ .map(IndexFileMeta::fileName)
+ .collect(Collectors.toList());
+ AtomicInteger attempts = new AtomicInteger();
+ AtomicReference<Snapshot> successfulSnapshot = new AtomicReference<>();
+ AtomicReference<Snapshot> reassignedSnapshot = new AtomicReference<>();
+ CatalogEnvironment environment =
+ new CatalogEnvironment(null, null, null, null, null, null,
false, false) {
+ @Override
+ public SnapshotCommit snapshotCommit(SnapshotManager
manager) {
+ SnapshotCommit delegate = new
RenamingSnapshotCommit(manager, Lock.empty());
+ return new SnapshotCommit() {
+ @Override
+ public boolean commit(
+ String baseUuid,
+ Snapshot snapshot,
+ String branch,
+ List<PartitionStatistics> statistics)
+ throws Exception {
+ boolean committed =
+ delegate.commit(baseUuid, snapshot,
branch, statistics);
+ if (snapshot.commitKind() !=
Snapshot.CommitKind.COMPACT) {
+ return committed;
+ }
+ attempts.incrementAndGet();
+ assertThat(committed).isTrue();
+ successfulSnapshot.set(snapshot);
+ List<IndexManifestEntry> outputs =
+
table.store().indexManifestFileFactory().create()
+
.read(snapshot.indexManifest()).stream()
+ .filter(
+ e ->
+
outputNames.contains(
+
e.indexFile().fileName()))
+ .collect(Collectors.toList());
+
assertThat(outputs).hasSize(outputNames.size());
+ Range obsoleteRange =
+
outputs.get(0).indexFile().globalIndexMeta().rowRange();
+ List<DataFileMeta> deleted =
+
table.store().newScan().withSnapshot(snapshot).plan()
+ .files().stream()
+ .filter(
+ e ->
+ e.partition()
+
.equals(
+
message
+
.partition()))
+ .map(ManifestEntry::file)
+ .filter(
+ f ->
+
f.nonNullFirstRowId()
+ ==
obsoleteRange.from)
+ .collect(Collectors.toList());
+ assertThat(deleted).hasSize(2);
+ assertThat(deleted)
+ .allSatisfy(f ->
assertThat(f.rowCount()).isEqualTo(1));
+ try (BatchTableCommit deletion =
+
table.newBatchWriteBuilder().newCommit()) {
+ deletion.commit(
+ Collections.singletonList(
+ new CommitMessageImpl(
+
message.partition(),
+ message.bucket(),
+ null,
+ new DataIncrement(
+
Collections.emptyList(),
+ deleted,
+
Collections.emptyList()),
+
CompactIncrement.emptyIncrement())));
+ }
+ // Keep the surviving partition ranges
fragmented so reassignment
+ // runs.
+ write(table, "b", 4, false);
+ write(table, "a", 5, false);
+ assertThat(
+ new
DataEvolutionRowIdReassigner(table)
+ .reassign()
+ .reassigned)
+ .isTrue();
+ Snapshot latest =
table.snapshotManager().latestSnapshot();
+ reassignedSnapshot.set(latest);
+ String plan =
SerializationAssignment.planFile(latest);
+ assertThat(plan).isNotNull();
+ SerializationAssignment assignment =
+ SerializationAssignment.readPlan(
+ table.fileIO(),
table.store().pathFactory(), plan);
+ assertThatThrownBy(
+ () ->
+ assignment.mapRowRange(
+
message.partition(), obsoleteRange))
+ .hasMessageContaining(
+ "only partially or
non-contiguously mapped");
+ assertThat(
+
table.store().indexManifestFileFactory().create()
+
.read(latest.indexManifest()).stream()
+ .map(e ->
e.indexFile().fileName()))
+
.doesNotContainAnyElementsOf(outputNames);
+ if (throwAfterSuccess) {
+ throw new IOException(
+ "Lost successful index compaction
response");
+ }
+ return false;
+ }
+
+ @Override
+ public void close() throws Exception {
+ delegate.close();
+ }
+ };
+ }
+ };
+ FileStoreTable committingTable =
+ FileStoreTableFactory.create(
+ table.fileIO(), table.location(), table.schema(),
environment);
+ commit(committingTable, message, true);
+ assertThat(attempts).hasValue(1);
+
assertThat(successfulSnapshot.get().commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+ assertThat(table.snapshotManager().latestSnapshotId())
+ .isEqualTo(reassignedSnapshot.get().id());
+ checkRows(table, 1, 2, 3, 4, 5);
+ }
+
+ @Test
+ void testReuseCompactAfterOverwriteOtherPartition() throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compact(table, false);
+ assertThat(new
DataEvolutionRowIdReassigner(table).reassign().reassigned).isTrue();
+ long reassignedSnapshot = table.snapshotManager().latestSnapshotId();
+
+ BatchWriteBuilder builder =
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.COMMIT_LAST_SAFE_SNAPSHOT.key(),
+
lastSafeSnapshots.get(message).toString()))
+ .newBatchWriteBuilder()
+ .withOverwrite(Collections.singletonMap("pt", "b"));
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ write.write(
+ GenericRow.of(
+ BinaryString.fromString("b"),
+ 9,
+ new
BlobData("image-9".getBytes(StandardCharsets.UTF_8))));
+ List<CommitMessage> messages = new
ArrayList<>(write.prepareCommit());
+ messages.add(message);
+ commit.commit(messages);
+ }
+
+ Snapshot latest = table.snapshotManager().latestSnapshot();
+ assertThat(latest.id()).isEqualTo(reassignedSnapshot + 2);
+ assertThat(latest.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+
assertThat(message.compactIncrement().compactAfter().get(0).nonNullFirstRowId()).isZero();
+ checkRows(table, 0, 1, 3, 9);
+ }
+
+ @Test
+ void testRealOverwriteStillConflicts() throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compact(table, false);
+ assertThat(new
DataEvolutionRowIdReassigner(table).reassign().reassigned).isTrue();
+ write(table, "a", 9, true);
+ long snapshot = table.snapshotManager().latestSnapshotId();
+ assertThatThrownBy(() -> commit(table, message,
false)).hasStackTraceContaining("conflict");
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshot);
+ checkRows(table, 2, 9);
+ }
+
+ @Test
+ void testReplacedInputsStillConflict() throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl first = compact(table, false);
+ CommitMessageImpl second = compact(table, false);
+ commit(table, first, false);
+ long snapshot = table.snapshotManager().latestSnapshotId();
+ assertThatThrownBy(() -> commit(table, second,
false)).hasStackTraceContaining("conflict");
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshot);
+ checkRows(table, 0, 1, 2, 3);
+ }
+
+ @Test
+ void testMissingPlanFailsClosed() throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compact(table, false);
+ new DataEvolutionRowIdReassigner(table).reassign();
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ Path plan =
+ table.store()
+ .pathFactory()
+
.toManifestFilePath(SerializationAssignment.planFile(snapshot));
+ table.fileIO().deleteQuietly(plan);
+ assertThatThrownBy(() -> commit(table, message, false))
+ .hasStackTraceContaining("Cannot read compaction reassignment
plan");
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshot.id());
+ checkRows(table, 0, 1, 2, 3);
+ }
+
+ @Test
+ void testCorruptPlanFailsClosed() throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compact(table, false);
+ new DataEvolutionRowIdReassigner(table).reassign();
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ Path path =
+ table.store()
+ .pathFactory()
+
.toManifestFilePath(SerializationAssignment.planFile(snapshot));
+ try (PositionOutputStream out = table.fileIO().newOutputStream(path,
true)) {
+ out.write(new byte[16]);
+ }
+ assertThatThrownBy(() -> commit(table, message,
false)).hasStackTraceContaining("checksum");
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshot.id());
+ checkRows(table, 0, 1, 2, 3);
+ }
+
+ @Test
+ void testNoBoundaryKeepsExistingConflictCheck() throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compact(table, false);
+ new DataEvolutionRowIdReassigner(table).reassign();
+ long snapshot = table.snapshotManager().latestSnapshotId();
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ assertThatThrownBy(() ->
commit.commit(Collections.singletonList(message)))
+ .hasStackTraceContaining("conflict");
+ }
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshot);
+ checkRows(table, 0, 1, 2, 3);
+ }
+
+ @Test
+ void testExpiredBoundaryAndLegacyOption() throws Exception {
+ FileStoreTable table = prepare();
+ CommitMessageImpl message = compact(table, false);
+ new DataEvolutionRowIdReassigner(table).reassign();
+ table.fileIO()
+ .deleteQuietly(
+
table.snapshotManager().snapshotPath(lastSafeSnapshots.get(message)));
+ commit(table, message, true, "commit.strict-mode.last-safe-snapshot");
+ checkRows(table, 0, 1, 2, 3);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testReassignDuringCommitRetries(boolean loseSuccessfulResponse)
throws Exception {
+ FileStoreTable original = prepare();
+ CommitMessageImpl message = compact(original, false);
+ AtomicInteger attempts = new AtomicInteger();
+ CatalogEnvironment environment =
+ new CatalogEnvironment(null, null, null, null, null, null,
false, false) {
+ @Override
+ public SnapshotCommit snapshotCommit(SnapshotManager
manager) {
+ SnapshotCommit delegate = new
RenamingSnapshotCommit(manager, Lock.empty());
+ return new SnapshotCommit() {
+ @Override
+ public boolean commit(
+ String baseUuid,
+ Snapshot snapshot,
+ String branch,
+ List<PartitionStatistics> statistics)
+ throws Exception {
+ if (snapshot.commitKind() !=
Snapshot.CommitKind.COMPACT) {
+ return delegate.commit(baseUuid, snapshot,
branch, statistics);
+ }
+ int attempt = attempts.getAndIncrement();
+ if (attempt == 0) {
+ assertThat(
+ new
DataEvolutionRowIdReassigner(original)
+ .reassign()
+ .reassigned)
+ .isTrue();
+ } else if (attempt == 1) {
+ write(original, "b", 4, false);
+ write(original, "a", 5, false);
+ assertThat(
+ new
DataEvolutionRowIdReassigner(original)
+ .reassign()
+ .reassigned)
+ .isTrue();
+ }
+ boolean committed =
+ delegate.commit(baseUuid, snapshot,
branch, statistics);
+ return committed && !loseSuccessfulResponse;
+ }
+
+ @Override
+ public void close() throws Exception {
+ delegate.close();
+ }
+ };
+ }
+ };
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ original.fileIO(), original.location(),
original.schema(), environment);
+ commit(table, message, true);
+ assertThat(attempts).hasValue(3);
+ assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(9);
+ checkRows(table, 0, 1, 2, 3, 4, 5);
+
assertThat(message.compactIncrement().compactAfter().get(0).nonNullFirstRowId()).isZero();
+ }
+
+ @Test
+ void testNoReassignmentDoesNotValidateMappingInputs() throws Exception {
+ FileStoreTable table = prepare();
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ ManifestEntry entry = table.store().newScan().plan().files().get(0);
+ IndexManifestEntry unsupported =
+ new IndexManifestEntry(
+ FileKind.ADD,
+ entry.partition(),
+ entry.bucket(),
+ new IndexFileMeta("unknown", "unsupported.index", 1,
1, null, null, null));
+ CommitChanges changes =
+ new CommitChanges(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.singletonList(unsupported));
+ ReassignCompactChangesProvider provider =
+ new ReassignCompactChangesProvider(
+ table.fileIO(),
+ table.store().pathFactory(),
+ table.snapshotManager(),
+ snapshot.id(),
+ changes);
+
+ write(table, "b", 4, false);
+
assertThat(provider.provide(table.snapshotManager().latestSnapshot())).isSameAs(changes);
+ assertThat(provider.rebasedReassignments()).isEmpty();
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "missing-row-id, has no first row ID",
+ "physical-row-id, physically stores _ROW_ID",
+ "changelog, with changelog files",
+ "unknown-index, unsupported index type"
+ })
+ void testUnsafeMappingFailsBeforeCommit(String kind, String
expectedMessage) throws Exception {
+ FileStoreTable table = prepare();
+ ManifestEntry entry =
+ table.store().newScan().plan().files().stream()
+ .filter(e -> e.file().nonNullFirstRowId() == 0)
+ .findFirst()
+ .get();
+ DataFileMeta file =
+ DataFileMeta.forAppend(
+ "unsupported.parquet",
+ 1,
+ 1,
+ SimpleStats.EMPTY_STATS,
+ 0,
+ 0,
+ table.schema().id(),
+ Collections.emptyList(),
+ null,
+ null,
+ null,
+ null,
+ kind.equals("missing-row-id") ? null : 0L,
+ kind.equals("physical-row-id")
+ ?
Collections.singletonList(SpecialFields.ROW_ID.name())
+ : null);
+ List<DataFileMeta> dataFiles =
+ kind.equals("missing-row-id") || kind.equals("physical-row-id")
+ ? Collections.singletonList(file)
+ : Collections.emptyList();
+ List<DataFileMeta> changelogFiles =
+ kind.equals("changelog")
+ ? Collections.singletonList(file)
+ : Collections.emptyList();
+ List<IndexFileMeta> indexFiles =
+ kind.equals("unknown-index")
+ ? Collections.singletonList(
+ new IndexFileMeta(
+ "unknown", "unsupported.index", 1, 1,
null, null, null))
+ : Collections.emptyList();
+ CommitMessageImpl message =
+ new CommitMessageImpl(
+ entry.partition(),
+ entry.bucket(),
+ null,
+ DataIncrement.emptyIncrement(),
+ new CompactIncrement(
+ Collections.emptyList(),
+ dataFiles,
+ changelogFiles,
+ indexFiles,
+ Collections.emptyList()));
+ lastSafeSnapshots.put(message,
table.snapshotManager().latestSnapshotId());
+ assertThat(new
DataEvolutionRowIdReassigner(table).reassign().reassigned).isTrue();
+ long snapshot = table.snapshotManager().latestSnapshotId();
+
+ assertThatThrownBy(() -> commit(table, message, false))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Cannot reuse compaction across
reassignment")
+ .hasMessageContaining(expectedMessage);
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshot);
+ checkRows(table, 0, 1, 2, 3);
+ }
+
+ @Test
+ void testNonContiguousMappingCannotReuseAFile() throws Exception {
+ FileStoreTable table = prepare();
+ ManifestEntry entry =
+ table.store().newScan().plan().files().stream()
+ .filter(e ->
e.partition().getString(0).toString().equals("a"))
+ .findFirst()
+ .get();
+ RowRangeMappingIndex mapping =
+ RowRangeMappingIndex.create(
+ Arrays.asList(
+ RowRangeMappingIndex.mapping(0, 0, 4),
+ RowRangeMappingIndex.mapping(1, 1, 7)));
+ Map<String, String> properties =
+ SerializationAssignment.writeProperties(
+ table,
+ table.snapshotManager().latestSnapshot(),
+ Collections.singletonMap(entry.partition(), mapping),
+ 4,
+ 8);
+ SerializationAssignment assignment =
+ SerializationAssignment.readPlan(
+ table.fileIO(),
+ table.store().pathFactory(),
+
properties.get(SerializationAssignment.PLAN_FILE_PROPERTY));
+ assertThatThrownBy(() -> assignment.mapRowRange(entry.partition(), new
Range(0, 1)))
+ .hasMessageContaining("non-contiguously mapped");
+ assertThatThrownBy(() -> assignment.mapRowRange(entry.partition(), new
Range(0, 2)))
+ .hasMessageContaining("non-contiguously mapped");
+ assertThat(assignment.mapRowRange(entry.partition(), new Range(10,
11)))
+ .isEqualTo(new Range(10, 11));
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
index 601ac71561..268d711333 100644
---
a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
@@ -20,11 +20,13 @@ package org.apache.paimon.manifest;
import org.apache.paimon.TestAppendFileStore;
import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryRowWriter;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.FileFormat;
import org.apache.paimon.format.FormatReaderFactory;
import org.apache.paimon.fs.Path;
import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.index.DeletionVectorMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.table.BucketMode;
@@ -40,6 +42,7 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import static
org.apache.paimon.index.IndexFileMetaSerializerTest.randomDeletionVectorIndexFile;
@@ -238,6 +241,106 @@ public class IndexManifestFileHandlerTest {
"Trying to delete global index file missing-index
which does not exist.");
}
+ @Test
+ public void testGlobalIndexDeleteRejectsStaleRowRange() throws Exception {
+ TestAppendFileStore fileStore =
+ TestAppendFileStore.createAppendStore(tempDir, new
HashMap<>());
+ IndexManifestFile manifestFile = createIndexManifestFile(fileStore);
+ IndexManifestEntry current = globalIndexEntry("index", 1000, 1099, 1);
+ String manifest =
+ manifestFile.writeIndexFiles(
+ null, Arrays.asList(current),
BucketMode.BUCKET_UNAWARE);
+
+ // The file name and row count match, but the DELETE predates row-id
reassignment.
+ IndexManifestEntry stale = globalIndexEntry("index", 0, 99,
1).toDeleteEntry();
+ IndexManifestEntry replacement = globalIndexEntry("replacement", 0,
99, 1);
+ assertThatThrownBy(
+ () ->
+ manifestFile.writeIndexFiles(
+ manifest,
+ Arrays.asList(stale, replacement),
+ BucketMode.BUCKET_UNAWARE))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Index file index metadata does not
match");
+ assertThat(manifestFile.read(manifest)).containsExactly(current);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testIndexDeleteRejectsDifferentPartitionOrBucket(boolean
deletionVector)
+ throws Exception {
+ TestAppendFileStore fileStore =
+ TestAppendFileStore.createAppendStore(tempDir, new
HashMap<>());
+ IndexManifestFile manifestFile = createIndexManifestFile(fileStore);
+ IndexManifestEntry current =
+ deletionVector
+ ? new IndexManifestEntry(
+ FileKind.ADD,
+ BinaryRow.EMPTY_ROW,
+ 0,
+ randomDeletionVectorIndexFile())
+ : globalIndexEntry("index", 0, 99, 1);
+ String manifest =
+ manifestFile.writeIndexFiles(
+ null, Arrays.asList(current),
BucketMode.BUCKET_UNAWARE);
+
+ BinaryRow partition = new BinaryRow(1);
+ BinaryRowWriter writer = new BinaryRowWriter(partition);
+ writer.writeInt(0, 1);
+ writer.complete();
+ List<IndexManifestEntry> invalidDeletes =
+ Arrays.asList(
+ new IndexManifestEntry(FileKind.DELETE, partition, 0,
current.indexFile()),
+ new IndexManifestEntry(
+ FileKind.DELETE, current.partition(), 1,
current.indexFile()));
+ for (IndexManifestEntry deleted : invalidDeletes) {
+ assertThatThrownBy(
+ () ->
+ manifestFile.writeIndexFiles(
+ manifest,
+ Arrays.asList(deleted),
+ BucketMode.BUCKET_UNAWARE))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("metadata does not match the current
entry");
+ }
+ assertThat(manifestFile.read(manifest)).containsExactly(current);
+ }
+
+ @Test
+ public void testDeletionVectorDeleteRejectsIncompleteRanges() throws
Exception {
+ TestAppendFileStore fileStore =
+ TestAppendFileStore.createAppendStore(tempDir, new
HashMap<>());
+ IndexManifestFile manifestFile = createIndexManifestFile(fileStore);
+ IndexFileMeta file = randomDeletionVectorIndexFile();
+ IndexManifestEntry current =
+ new IndexManifestEntry(FileKind.ADD, BinaryRow.EMPTY_ROW, 0,
file);
+ String manifest =
+ manifestFile.writeIndexFiles(
+ null, Arrays.asList(current),
BucketMode.BUCKET_UNAWARE);
+
+ LinkedHashMap<String, DeletionVectorMeta> incomplete = new
LinkedHashMap<>(file.dvRanges());
+ incomplete.remove(incomplete.keySet().iterator().next());
+ IndexManifestEntry stale =
+ new IndexManifestEntry(
+ FileKind.DELETE,
+ current.partition(),
+ current.bucket(),
+ new IndexFileMeta(
+ file.indexType(),
+ file.fileName(),
+ file.fileSize(),
+ file.rowCount(),
+ incomplete,
+ file.externalPath()));
+ assertThatThrownBy(
+ () ->
+ manifestFile.writeIndexFiles(
+ manifest, Arrays.asList(stale),
BucketMode.BUCKET_UNAWARE))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("metadata does not match the current
entry");
+ assertThat(manifestFile.read(manifest)).containsExactly(current);
+ }
+
@Test
public void testDataEvolutionSourceMetaDoesNotDisableRangeValidation()
throws Exception {
TestAppendFileStore fileStore =
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 7a0d26b160..d030b3aee8 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
@@ -985,9 +985,11 @@ public class FileStoreCommitTest {
globalIndexDeleteEntry(partition, 0,
"materialized-bucket-index");
IndexManifestEntry otherBucketDelete =
globalIndexDeleteEntry(partition, 1, "other-bucket-index");
- ManifestEntryChanges changes = new ManifestEntryChanges(2);
- changes.compactIndexFiles.add(materializedBucketDelete);
- changes.compactIndexFiles.add(otherBucketDelete);
+ CommitChanges changes =
+ new CommitChanges(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Arrays.asList(materializedBucketDelete,
otherBucketDelete));
try (FileStoreCommitImpl commit = store.newCommit()) {
CommitChanges refreshed =