diff --git a/paimon-core/src/main/java/org/apache/paimon/append/SortCompactCommitMessageRewriter.java b/paimon-core/src/main/java/org/apache/paimon/append/SortCompactCommitMessageRewriter.java
new file mode 100644
index 000000000000..7e0ef449e74d
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/append/SortCompactCommitMessageRewriter.java
@@ -0,0 +1,683 @@
+/*
+ * 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;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.deletionvectors.append.AppendDeleteFileMaintainer;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
+import org.apache.paimon.index.DeletionVectorMeta;
+import org.apache.paimon.index.IndexFileHandler;
+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.FileStoreScan;
+import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.sink.TableCommit;
+import org.apache.paimon.table.source.DataSplit;
+
+import javax.annotation.Nullable;
+
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
+
+/**
+ * Rewrites the {@link CommitMessage}s produced by a sort compact write into compact commit
+ * messages, so that the commit is a {@link Snapshot.CommitKind#COMPACT} commit instead of an {@link
+ * Snapshot.CommitKind#OVERWRITE} commit.
+ *
+ *
The sort compact write only creates new data files (the sorted output). The old files which
+ * are replaced by the sort compact are captured by the planned input {@link DataSplit}s. This
+ * helper rewrites them into compact changes: all planned old files become {@code compactBefore} for
+ * their original (partition, bucket), and all newly written files become {@code compactAfter} for
+ * their output (partition, bucket). The result is a {@link CommitMessageImpl} with an empty {@link
+ * DataIncrement} and a populated {@link CompactIncrement}.
+ *
+ *
For deletion-vector enabled append tables, only the deletion-vector index entries captured
+ * from the base snapshot are cleaned up, mirroring {@link
+ * org.apache.paimon.append.AppendCompactTask}. Concurrent deletion-vector writes after the base
+ * snapshot are not merged into cleanup; if deletion vectors on input files changed, rewrite
+ * or commit conflict detection fails with an explicit error so the job can be retried.
+ */
+public class SortCompactCommitMessageRewriter {
+
+ private static final String ABORT_COMMIT_USER = "sort-compact-abort";
+ private static final int DV_DRIFT_SAMPLE_LIMIT = 5;
+
+ private final FileStoreTable table;
+ private final long baseSnapshotId;
+
+ /** Old files grouped by partition then bucket, captured from the planned input splits. */
+ private final Map>> compactBeforeFiles;
+
+ /**
+ * Total bucket counts grouped like {@link #compactBeforeFiles}, when carried by input splits.
+ */
+ private final Map> compactBeforeTotalBuckets;
+
+ /**
+ * Deletion-vector index entries captured from the base snapshot at planning time, grouped by
+ * partition.
+ */
+ private final Map> baseDeletionVectorEntries;
+
+ /**
+ * Whether {@link #baseDeletionVectorEntries} is a known base-snapshot state (including a known
+ * empty map). False only when the base snapshot was already missing at construction and no
+ * {@link SortCompactPlanMetadata} was provided.
+ */
+ private final boolean baseDeletionVectorStateKnown;
+
+ public SortCompactCommitMessageRewriter(
+ FileStoreTable table, long baseSnapshotId, List compactInputSplits) {
+ this(table, baseSnapshotId, compactInputSplits, null);
+ }
+
+ public SortCompactCommitMessageRewriter(
+ FileStoreTable table,
+ long baseSnapshotId,
+ List compactInputSplits,
+ @Nullable SortCompactPlanMetadata planMetadata) {
+ this.table = table;
+ this.baseSnapshotId = baseSnapshotId;
+ this.compactBeforeFiles = new HashMap<>();
+ this.compactBeforeTotalBuckets = new HashMap<>();
+ this.baseDeletionVectorEntries = new HashMap<>();
+ Set partitions = new HashSet<>();
+ for (DataSplit split : compactInputSplits) {
+ partitions.add(split.partition());
+ compactBeforeFiles
+ .computeIfAbsent(split.partition(), k -> new HashMap<>())
+ .computeIfAbsent(split.bucket(), k -> new ArrayList<>())
+ .addAll(split.dataFiles());
+ if (split.totalBuckets() != null) {
+ Integer previous =
+ compactBeforeTotalBuckets
+ .computeIfAbsent(split.partition(), k -> new HashMap<>())
+ .putIfAbsent(split.bucket(), split.totalBuckets());
+ if (previous != null && !previous.equals(split.totalBuckets())) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Conflicting total bucket counts for partition %s bucket %s: "
+ + "%s and %s.",
+ split.partition(),
+ split.bucket(),
+ previous,
+ split.totalBuckets()));
+ }
+ }
+ }
+ if (planMetadata != null) {
+ planMetadata.copyInto(baseDeletionVectorEntries);
+ this.baseDeletionVectorStateKnown = planMetadata.baseSnapshotCaptured();
+ } else {
+ this.baseDeletionVectorStateKnown =
+ SortCompactPlanMetadata.captureInto(
+ table, baseSnapshotId, partitions, baseDeletionVectorEntries);
+ }
+ }
+
+ /**
+ * Rewrite the given written append commit messages into compact commit messages.
+ *
+ * Both the planned input splits and the written messages are grouped by (partition, bucket).
+ * Planned input groups are always emitted, even if the sort compact write produces no files for
+ * that group. Written output groups which were not present in the planned input are emitted as
+ * add-only compact messages.
+ *
+ * @param writtenMessages commit messages produced by the sort compact write stage (only new
+ * files in {@link DataIncrement})
+ * @return rewritten commit messages carrying {@link CompactIncrement}s
+ */
+ public List rewrite(List writtenMessages) {
+ validateWriteOnlyMessages(writtenMessages);
+ validateNoDeletionVectorDrift(writtenMessages);
+
+ // group written messages by (partition, bucket)
+ Map>> grouped = new HashMap<>();
+ for (CommitMessage written : writtenMessages) {
+ CommitMessageImpl impl = (CommitMessageImpl) written;
+ grouped.computeIfAbsent(impl.partition(), k -> new HashMap<>())
+ .computeIfAbsent(impl.bucket(), k -> new ArrayList<>())
+ .add(impl);
+ }
+
+ List result = new ArrayList<>();
+ try {
+ for (Map.Entry>> partitionEntry :
+ compactBeforeFiles.entrySet()) {
+ BinaryRow partition = partitionEntry.getKey();
+ Map> writtenInPartition = grouped.get(partition);
+ for (Integer bucket : partitionEntry.getValue().keySet()) {
+ List group = Collections.emptyList();
+ if (writtenInPartition != null) {
+ List writtenGroup = writtenInPartition.remove(bucket);
+ if (writtenGroup != null) {
+ group = writtenGroup;
+ }
+ }
+ result.add(rewriteGroup(partition, bucket, group));
+ }
+ if (writtenInPartition != null && writtenInPartition.isEmpty()) {
+ grouped.remove(partition);
+ }
+ }
+
+ for (Map.Entry>> partitionEntry :
+ grouped.entrySet()) {
+ BinaryRow partition = partitionEntry.getKey();
+ for (Map.Entry> bucketEntry :
+ partitionEntry.getValue().entrySet()) {
+ result.add(
+ rewriteGroup(partition, bucketEntry.getKey(), bucketEntry.getValue()));
+ }
+ }
+ } catch (RuntimeException e) {
+ // A partial rewrite may have already persisted new DV index files in result.
+ // The sorted output files in writtenMessages must also be aborted so callers
+ // (for example Flink's rewriteAll outside commit try/catch) do not leave orphans.
+ abortQuietly(result, e);
+ abortQuietly(writtenMessages, e);
+ throw e;
+ }
+ return result;
+ }
+
+ /**
+ * Validate that the written messages only contain write-only append output. Sort compact must
+ * not run inline compaction in the write stage; otherwise compact output would be dropped and
+ * orphan files would be left on disk.
+ */
+ private void validateWriteOnlyMessages(List writtenMessages) {
+ for (CommitMessage written : writtenMessages) {
+ CommitMessageImpl impl = (CommitMessageImpl) written;
+ CompactIncrement compactIncrement = impl.compactIncrement();
+ if (!compactIncrement.compactBefore().isEmpty()
+ || !compactIncrement.compactAfter().isEmpty()) {
+ abortAndFail(
+ writtenMessages,
+ String.format(
+ "Sort compact write produced inline compaction changes for "
+ + "partition %s bucket %s (compactBefore = %s, "
+ + "compactAfter = %s). The write stage must run in "
+ + "write-only mode without waiting for compaction.",
+ impl.partition(),
+ impl.bucket(),
+ compactIncrement.compactBefore(),
+ compactIncrement.compactAfter()));
+ }
+ }
+ }
+
+ /**
+ * Fail fast when deletion vectors on compact-before files changed after the base snapshot.
+ *
+ * Sort compact reads rows from the base snapshot. Committing after a concurrent DV write
+ * would drop the newer deletion vectors and restore deleted rows. This check only validates;
+ * cleanup still uses {@link #baseDeletionVectorEntries} only.
+ */
+ private void validateNoDeletionVectorDrift(List writtenMessages) {
+ if (!table.coreOptions().deletionVectorsEnabled()
+ || table.bucketMode() != BucketMode.BUCKET_UNAWARE
+ || !hasInput()) {
+ return;
+ }
+
+ // Without a known base DV state we cannot tell whether DVs changed; skip the proactive
+ // check and rely on commit conflict detection.
+ if (!baseDeletionVectorStateKnown) {
+ return;
+ }
+
+ Snapshot latestSnapshot = tryLatestSnapshot();
+ // No readable latest snapshot (e.g. the only snapshot was expired): nothing to compare.
+ if (latestSnapshot == null) {
+ return;
+ }
+
+ Map> latestDeletionVectorEntries =
+ scanDeletionVectorEntries(latestSnapshot);
+ Long latestSnapshotId = latestSnapshot.id();
+
+ BinaryRow firstChangedPartition = null;
+ List changedSamples = new ArrayList<>();
+ for (Map.Entry>> partitionEntry :
+ compactBeforeFiles.entrySet()) {
+ BinaryRow partition = partitionEntry.getKey();
+ Map baseDvByDataFile =
+ dataFileToDvIndexFileName(
+ baseDeletionVectorEntries.getOrDefault(
+ partition, Collections.emptyList()));
+ Map latestDvByDataFile =
+ dataFileToDvIndexFileName(
+ latestDeletionVectorEntries.getOrDefault(
+ partition, Collections.emptyList()));
+ for (List files : partitionEntry.getValue().values()) {
+ for (DataFileMeta file : files) {
+ String baseDv = baseDvByDataFile.get(file.fileName());
+ String latestDv = latestDvByDataFile.get(file.fileName());
+ if (Objects.equals(baseDv, latestDv)) {
+ continue;
+ }
+ if (firstChangedPartition == null) {
+ firstChangedPartition = partition;
+ }
+ if (changedSamples.size() < DV_DRIFT_SAMPLE_LIMIT) {
+ changedSamples.add(
+ String.format(
+ "%s (baseDv=%s -> latestDv=%s)",
+ file.fileName(), baseDv, latestDv));
+ }
+ }
+ }
+ }
+
+ if (firstChangedPartition != null) {
+ abortAndFail(
+ writtenMessages,
+ deletionVectorDriftMessage(
+ firstChangedPartition, changedSamples, latestSnapshotId));
+ }
+ }
+
+ private String deletionVectorDriftMessage(
+ BinaryRow partition, List changedSamples, @Nullable Long latestSnapshotId) {
+ return "Sort compact cannot commit because deletion vectors on input files changed after the base snapshot. "
+ + "Sort compact reads data from the base snapshot, so committing would drop newer deletion vectors and restore deleted rows. "
+ + "Changed files (partition="
+ + partition
+ + ", sample): "
+ + String.join(", ", changedSamples)
+ + ". baseSnapshotId="
+ + baseSnapshotId
+ + ", latestSnapshotId="
+ + latestSnapshotId
+ + ". Please retry the sort compact job after concurrent deletes/updates have finished.";
+ }
+
+ /**
+ * Abort newly written files when sort compact rewrite or commit fails.
+ *
+ * This only covers files tracked by the original append write messages. The new
+ * deletion-vector index files produced by {@code dvMaintainer.persist()} during {@link
+ * #rewrite} live in the rewritten compact messages and must be cleaned up via {@link
+ * #abortCompactMessages}.
+ */
+ public void abortWrittenMessages(List writtenMessages) {
+ abortMessages(writtenMessages);
+ }
+
+ /**
+ * Abort the rewritten compact messages, including the new deletion-vector index files produced
+ * by {@code dvMaintainer.persist()} during {@link #rewrite}.
+ *
+ * These index files are not referenced by the original written messages, so aborting only
+ * the written messages (as failure cleanup used to do) orphans them. For delete-only compact
+ * the written messages are empty, so the rewritten compact messages are the only place the new
+ * DV index files are tracked. {@code commit.abort} only deletes new files ({@code
+ * compactAfter}, {@code newIndexFiles}); planned {@code compactBefore} files and {@code
+ * deletedIndexFiles} (still referenced by the latest snapshot) are left untouched.
+ */
+ public void abortCompactMessages(List compactMessages) {
+ abortMessages(compactMessages);
+ }
+
+ private void abortMessages(List messages) {
+ if (messages.isEmpty()) {
+ return;
+ }
+ try (TableCommit commit = table.newCommit(ABORT_COMMIT_USER)) {
+ commit.abort(messages);
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "Failed to clean up sort compact write output before aborting commit.", e);
+ }
+ }
+
+ private void abortQuietly(List messages, RuntimeException cause) {
+ try {
+ abortMessages(messages);
+ } catch (Exception abortException) {
+ cause.addSuppressed(abortException);
+ }
+ }
+
+ private void abortAndFail(List writtenMessages, String message) {
+ abortWrittenMessages(writtenMessages);
+ throw new IllegalStateException(message);
+ }
+
+ private CommitMessage rewriteGroup(
+ BinaryRow partition, int bucket, List group) {
+ List compactBefore = compactBefore(partition, bucket);
+
+ // merge all newly written sorted files of this (partition, bucket) as compact output
+ List compactAfter = new ArrayList<>();
+ List newIndexFiles = new ArrayList<>();
+ List deletedIndexFiles = new ArrayList<>();
+ Integer totalBuckets = null;
+ for (CommitMessageImpl impl : group) {
+ compactAfter.addAll(toCompactAfter(impl.newFilesIncrement().newFiles()));
+ newIndexFiles.addAll(impl.compactIncrement().newIndexFiles());
+ newIndexFiles.addAll(impl.newFilesIncrement().newIndexFiles());
+ deletedIndexFiles.addAll(impl.compactIncrement().deletedIndexFiles());
+ deletedIndexFiles.addAll(impl.newFilesIncrement().deletedIndexFiles());
+ if (totalBuckets == null) {
+ totalBuckets = impl.totalBuckets();
+ }
+ }
+ if (totalBuckets == null) {
+ totalBuckets = compactBeforeTotalBuckets(partition, bucket);
+ }
+
+ // for deletion-vector append tables, clean up only the base-snapshot DV index entries of
+ // removed old files (do not merge concurrent latest-snapshot DVs)
+ if (table.coreOptions().deletionVectorsEnabled()
+ && table.bucketMode() == BucketMode.BUCKET_UNAWARE
+ && !compactBefore.isEmpty()) {
+ AppendDeleteFileMaintainer dvMaintainer =
+ BaseAppendDeleteFileMaintainer.forUnawareAppend(
+ table.store().newIndexFileHandler(),
+ partition,
+ baseDeletionVectorEntries.getOrDefault(
+ partition, Collections.emptyList()));
+ for (DataFileMeta oldFile : compactBefore) {
+ dvMaintainer.notifyRemovedDeletionVector(oldFile.fileName());
+ }
+ for (IndexManifestEntry entry : dvMaintainer.persist()) {
+ if (entry.kind() == FileKind.ADD) {
+ newIndexFiles.add(entry.indexFile());
+ } else {
+ deletedIndexFiles.add(entry.indexFile());
+ }
+ }
+ }
+
+ CompactIncrement compactIncrement =
+ new CompactIncrement(
+ compactBefore,
+ compactAfter,
+ Collections.emptyList(),
+ newIndexFiles,
+ deletedIndexFiles);
+ return new CommitMessageImpl(
+ partition, bucket, totalBuckets, DataIncrement.emptyIncrement(), compactIncrement);
+ }
+
+ private Map> scanDeletionVectorEntries(
+ @Nullable Snapshot snapshot) {
+ Map> entries = new HashMap<>();
+ if (snapshot == null) {
+ return entries;
+ }
+ IndexFileHandler indexFileHandler = table.store().newIndexFileHandler();
+ for (IndexManifestEntry entry : indexFileHandler.scan(snapshot, DELETION_VECTORS_INDEX)) {
+ entries.computeIfAbsent(entry.partition(), k -> new ArrayList<>()).add(entry);
+ }
+ return entries;
+ }
+
+ private static Map dataFileToDvIndexFileName(List entries) {
+ Map result = new HashMap<>();
+ for (IndexManifestEntry entry : entries) {
+ LinkedHashMap dvRanges = entry.indexFile().dvRanges();
+ if (dvRanges == null) {
+ continue;
+ }
+ String indexFileName = entry.indexFile().fileName();
+ for (String dataFileName : dvRanges.keySet()) {
+ result.put(dataFileName, indexFileName);
+ }
+ }
+ return result;
+ }
+
+ @Nullable
+ private Snapshot tryLatestSnapshot() {
+ Long latestId = table.snapshotManager().latestSnapshotId();
+ if (latestId == null) {
+ return null;
+ }
+ try {
+ return table.snapshotManager().tryGetSnapshot(latestId);
+ } catch (FileNotFoundException e) {
+ return null;
+ }
+ }
+
+ private List toCompactAfter(List newFiles) {
+ if (newFiles.isEmpty()) {
+ return newFiles;
+ }
+ List result = new ArrayList<>(newFiles.size());
+ for (DataFileMeta newFile : newFiles) {
+ if (newFile.fileSource().orElse(null)
+ == org.apache.paimon.manifest.FileSource.COMPACT) {
+ result.add(newFile);
+ continue;
+ }
+ result.add(newFile.assignFileSource(org.apache.paimon.manifest.FileSource.COMPACT));
+ }
+ return result;
+ }
+
+ private List compactBefore(BinaryRow partition, int bucket) {
+ return compactBeforeFiles
+ .getOrDefault(partition, Collections.emptyMap())
+ .getOrDefault(bucket, Collections.emptyList());
+ }
+
+ @Nullable
+ private Integer compactBeforeTotalBuckets(BinaryRow partition, int bucket) {
+ return compactBeforeTotalBuckets
+ .getOrDefault(partition, Collections.emptyMap())
+ .get(bucket);
+ }
+
+ /** Latest snapshot id, or 0 when the table has no snapshot yet. */
+ public long latestSnapshotIdOrZero() {
+ Long latestId = table.snapshotManager().latestSnapshotId();
+ return latestId == null ? 0L : latestId;
+ }
+
+ /**
+ * Whether the given compact commit messages were already committed after {@code
+ * snapshotIdBeforeCommit}.
+ *
+ * Used to avoid aborting sort compact write output when {@link
+ * org.apache.paimon.table.sink.TableCommitImpl} fails after the snapshot is already visible.
+ * Matching is based on the compact output file set (or removed input files for delete-only
+ * commits), not on any unrelated batch COMPACT snapshot.
+ */
+ public boolean isBatchCompactCommitSucceeded(
+ long snapshotIdBeforeCommit, List compactMessages) {
+ Long latestId = table.snapshotManager().latestSnapshotId();
+ if (latestId == null || latestId <= snapshotIdBeforeCommit) {
+ return false;
+ }
+
+ CompactCommitFingerprint fingerprint = CompactCommitFingerprint.from(compactMessages);
+ Snapshot latestSnapshot = table.snapshotManager().snapshot(latestId);
+ if (matchesCompactCommit(latestSnapshot, fingerprint)) {
+ return true;
+ }
+
+ // Check older snapshots in reverse order. The compact commit is usually near latest, and
+ // scoped scans below avoid full-table manifest reads when probing history.
+ for (long id = latestId - 1; id > snapshotIdBeforeCommit; id--) {
+ Snapshot snapshot;
+ try {
+ snapshot = table.snapshotManager().tryGetSnapshot(id);
+ } catch (FileNotFoundException e) {
+ // Expired snapshots create gaps. Keep scanning older snapshots instead of treating
+ // the commit as failed.
+ continue;
+ }
+ if (matchesCompactCommit(snapshot, fingerprint)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean matchesCompactCommit(Snapshot snapshot, CompactCommitFingerprint fingerprint) {
+ if (!fingerprint.compactAfterFileNames.isEmpty()) {
+ // Output file names are unique and snapshot commit is atomic. Finding any compact
+ // output in a surviving snapshot proves the batch compact commit succeeded, even when
+ // concurrent compaction has replaced other outputs or the COMPACT snapshot expired.
+ return snapshotContainsAnyFile(
+ snapshot, fingerprint, fingerprint.compactAfterFileNames);
+ }
+ if (snapshot.commitIdentifier() != BatchWriteBuilder.COMMIT_IDENTIFIER
+ || snapshot.commitKind() != Snapshot.CommitKind.COMPACT) {
+ return false;
+ }
+ if (!fingerprint.compactBeforeFileNames.isEmpty()) {
+ return !snapshotContainsAnyFile(
+ snapshot, fingerprint, fingerprint.compactBeforeFileNames);
+ }
+ return true;
+ }
+
+ private boolean snapshotContainsAnyFile(
+ Snapshot snapshot, CompactCommitFingerprint fingerprint, Set fileNames) {
+ if (fileNames.isEmpty()) {
+ return false;
+ }
+ for (ManifestEntry entry :
+ createScopedScan(snapshot, fingerprint, fileNames).plan().files()) {
+ if (fileNames.contains(entry.file().fileName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private FileStoreScan createScopedScan(
+ Snapshot snapshot, CompactCommitFingerprint fingerprint, Set fileNames) {
+ FileStoreScan scan = table.store().newScan().withSnapshot(snapshot).dropStats();
+ if (!fingerprint.partitions.isEmpty()) {
+ scan = scan.withPartitionFilter(fingerprint.partitions);
+ }
+ if (!fingerprint.buckets.isEmpty()) {
+ scan = scan.withBucketFilter(fingerprint.buckets::contains);
+ }
+ if (!fileNames.isEmpty()) {
+ scan = scan.withDataFileNameFilter(fileNames::contains);
+ }
+ return scan;
+ }
+
+ private static final class CompactCommitFingerprint {
+ private final Set compactAfterFileNames;
+ private final Set compactBeforeFileNames;
+ private final List partitions;
+ private final Set buckets;
+
+ private CompactCommitFingerprint(
+ Set compactAfterFileNames,
+ Set compactBeforeFileNames,
+ List partitions,
+ Set buckets) {
+ this.compactAfterFileNames = compactAfterFileNames;
+ this.compactBeforeFileNames = compactBeforeFileNames;
+ this.partitions = partitions;
+ this.buckets = buckets;
+ }
+
+ private static CompactCommitFingerprint from(List compactMessages) {
+ Set compactAfterFileNames = new HashSet<>();
+ Set compactBeforeFileNames = new HashSet<>();
+ Set partitionSet = new HashSet<>();
+ Set buckets = new HashSet<>();
+ for (CommitMessage message : compactMessages) {
+ CommitMessageImpl impl = (CommitMessageImpl) message;
+ partitionSet.add(impl.partition());
+ buckets.add(impl.bucket());
+ for (DataFileMeta file : impl.compactIncrement().compactAfter()) {
+ compactAfterFileNames.add(file.fileName());
+ }
+ for (DataFileMeta file : impl.compactIncrement().compactBefore()) {
+ compactBeforeFileNames.add(file.fileName());
+ }
+ }
+ return new CompactCommitFingerprint(
+ compactAfterFileNames,
+ compactBeforeFileNames,
+ new ArrayList<>(partitionSet),
+ buckets);
+ }
+ }
+
+ /** Whether all planned compact-before files are already absent from the latest snapshot. */
+ public boolean isPlannedInputAlreadyCommitted() {
+ if (!hasInput()) {
+ return true;
+ }
+ Long latestId = table.snapshotManager().latestSnapshotId();
+ if (latestId == null) {
+ return false;
+ }
+ Snapshot snapshot = table.snapshotManager().snapshot(latestId);
+ Set beforeFileNames = new HashSet<>();
+ Set partitionSet = new HashSet<>();
+ Set buckets = new HashSet<>();
+ for (Map.Entry>> partitionEntry :
+ compactBeforeFiles.entrySet()) {
+ partitionSet.add(partitionEntry.getKey());
+ for (Map.Entry> bucketEntry :
+ partitionEntry.getValue().entrySet()) {
+ buckets.add(bucketEntry.getKey());
+ for (DataFileMeta file : bucketEntry.getValue()) {
+ beforeFileNames.add(file.fileName());
+ }
+ }
+ }
+ CompactCommitFingerprint fingerprint =
+ new CompactCommitFingerprint(
+ Collections.emptySet(),
+ beforeFileNames,
+ new ArrayList<>(partitionSet),
+ buckets);
+ return !snapshotContainsAnyFile(snapshot, fingerprint, beforeFileNames);
+ }
+
+ /** Whether this rewriter has captured any old files to compact. */
+ public boolean hasInput() {
+ return !compactBeforeFiles.isEmpty();
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/append/SortCompactPlanMetadata.java b/paimon-core/src/main/java/org/apache/paimon/append/SortCompactPlanMetadata.java
new file mode 100644
index 000000000000..ebd7802fc3fc
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/append/SortCompactPlanMetadata.java
@@ -0,0 +1,165 @@
+/*
+ * 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;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.IndexManifestEntrySerializer;
+import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
+
+/**
+ * Base-snapshot index metadata captured at sort compact planning time.
+ *
+ * Flink carries this object in the job graph so commit recovery can still clean up deletion
+ * vectors even if the base snapshot has expired before the committer runs again. Index metadata is
+ * stored as Paimon byte arrays instead of non-{@link Serializable} POJOs.
+ */
+public final class SortCompactPlanMetadata implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Nullable private final byte[] serializedDeletionVectorEntries;
+
+ /**
+ * Whether the base snapshot was readable when this metadata was captured. Distinguishes a
+ * successful capture of an empty deletion-vector state from a failed capture.
+ */
+ private final boolean baseSnapshotCaptured;
+
+ private SortCompactPlanMetadata(
+ @Nullable byte[] serializedDeletionVectorEntries, boolean baseSnapshotCaptured) {
+ this.serializedDeletionVectorEntries = serializedDeletionVectorEntries;
+ this.baseSnapshotCaptured = baseSnapshotCaptured;
+ }
+
+ /** Capture index metadata from the base snapshot for the planned compact input. */
+ public static SortCompactPlanMetadata capture(
+ FileStoreTable table, long baseSnapshotId, List compactInputSplits) {
+ Set partitions = new HashSet<>();
+ for (DataSplit split : compactInputSplits) {
+ partitions.add(split.partition());
+ }
+
+ Map> baseDeletionVectorEntries = new HashMap<>();
+ boolean captured =
+ captureInto(table, baseSnapshotId, partitions, baseDeletionVectorEntries);
+ return fromCapturedMap(baseDeletionVectorEntries, captured);
+ }
+
+ static boolean captureInto(
+ FileStoreTable table,
+ long baseSnapshotId,
+ Set partitions,
+ Map> baseDeletionVectorEntries) {
+ Snapshot snapshot = resolveBaseSnapshot(table, baseSnapshotId);
+ if (snapshot == null) {
+ return false;
+ }
+
+ IndexFileHandler indexFileHandler = table.store().newIndexFileHandler();
+ if (table.coreOptions().deletionVectorsEnabled()
+ && table.bucketMode() == BucketMode.BUCKET_UNAWARE) {
+ for (IndexManifestEntry entry :
+ indexFileHandler.scan(snapshot, DELETION_VECTORS_INDEX)) {
+ if (partitions.contains(entry.partition())) {
+ baseDeletionVectorEntries
+ .computeIfAbsent(entry.partition(), k -> new ArrayList<>())
+ .add(entry);
+ }
+ }
+ }
+ return true;
+ }
+
+ void copyInto(Map> baseDeletionVectorEntries) {
+ if (serializedDeletionVectorEntries != null) {
+ IndexManifestEntrySerializer entrySerializer = new IndexManifestEntrySerializer();
+ try {
+ for (IndexManifestEntry entry :
+ entrySerializer.deserializeList(serializedDeletionVectorEntries)) {
+ baseDeletionVectorEntries
+ .computeIfAbsent(entry.partition(), k -> new ArrayList<>())
+ .add(entry);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Failed to deserialize captured deletion vector metadata.", e);
+ }
+ }
+ }
+
+ /**
+ * Whether the base snapshot was readable at capture time. An empty deletion-vector payload with
+ * {@code true} means known-empty; with {@code false} means capture failed.
+ */
+ boolean baseSnapshotCaptured() {
+ return baseSnapshotCaptured;
+ }
+
+ private static SortCompactPlanMetadata fromCapturedMap(
+ Map> baseDeletionVectorEntries,
+ boolean baseSnapshotCaptured) {
+ byte[] serializedDeletionVectorEntries = null;
+ if (!baseDeletionVectorEntries.isEmpty()) {
+ List entries = new ArrayList<>();
+ for (List partitionEntries : baseDeletionVectorEntries.values()) {
+ entries.addAll(partitionEntries);
+ }
+ IndexManifestEntrySerializer entrySerializer = new IndexManifestEntrySerializer();
+ try {
+ serializedDeletionVectorEntries = entrySerializer.serializeList(entries);
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Failed to serialize captured deletion vector metadata.", e);
+ }
+ }
+
+ return new SortCompactPlanMetadata(serializedDeletionVectorEntries, baseSnapshotCaptured);
+ }
+
+ @Nullable
+ private static Snapshot resolveBaseSnapshot(FileStoreTable table, long snapshotId) {
+ if (snapshotId <= 0) {
+ return table.snapshotManager().latestSnapshot();
+ }
+ try {
+ return table.snapshotManager().tryGetSnapshot(snapshotId);
+ } catch (java.io.FileNotFoundException e) {
+ return null;
+ }
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/BinaryDataFileMeta.java b/paimon-core/src/main/java/org/apache/paimon/io/BinaryDataFileMeta.java
index be56deb20727..9b19bd4f8b29 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/BinaryDataFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/BinaryDataFileMeta.java
@@ -281,6 +281,11 @@ public DataFileMeta assignSequenceNumber(long minSequenceNumber, long maxSequenc
throw unsupportedOperation("assignSequenceNumber(long, long)");
}
+ @Override
+ public DataFileMeta assignFileSource(FileSource fileSource) {
+ throw unsupportedOperation("assignFileSource(FileSource)");
+ }
+
@Override
public DataFileMeta assignFirstRowId(long firstRowId) {
throw unsupportedOperation("assignFirstRowId(long)");
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java b/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java
index f8b4e6aaf5b7..6723f3c894dd 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java
@@ -362,6 +362,8 @@ default Range nonNullRowIdRange() {
DataFileMeta assignSequenceNumber(long minSequenceNumber, long maxSequenceNumber);
+ DataFileMeta assignFileSource(FileSource fileSource);
+
DataFileMeta assignFirstRowId(long firstRowId);
DataFileMeta newFirstRowId(@Nullable Long newFirstRowId);
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java b/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java
index 9b288d1d5f7f..7c8bf3256235 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java
@@ -341,6 +341,31 @@ public PojoDataFileMeta assignSequenceNumber(long minSequenceNumber, long maxSeq
writeCols);
}
+ @Override
+ public PojoDataFileMeta assignFileSource(FileSource fileSource) {
+ return new PojoDataFileMeta(
+ fileName,
+ fileSize,
+ rowCount,
+ minKey,
+ maxKey,
+ keyStats,
+ valueStats,
+ minSequenceNumber,
+ maxSequenceNumber,
+ schemaId,
+ level,
+ extraFiles,
+ creationTime,
+ deleteRowCount,
+ embeddedIndex,
+ fileSource,
+ valueStatsCols,
+ externalPath,
+ firstRowId,
+ writeCols);
+ }
+
@Override
public PojoDataFileMeta assignFirstRowId(long firstRowId) {
return new PojoDataFileMeta(
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 34c5a29a4612..002b98bc6140 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
@@ -186,7 +186,8 @@ public Optional checkConflicts(
buildDeltaEntriesWithDV(baseEntries, deltaEntries, deltaIndexEntries);
} catch (Throwable e) {
return Optional.of(
- conflictException(commitUser, baseEntries, deltaEntries).apply(e));
+ conflictException(commitUser, baseEntries, deltaEntries, commitKind)
+ .apply(e));
}
}
@@ -201,7 +202,7 @@ public Optional checkConflicts(
}
Function conflictException =
- conflictException(baseCommitUser, baseEntries, deltaEntries);
+ conflictException(baseCommitUser, baseEntries, deltaEntries, commitKind);
try {
// check the delta, it is important not to delete and add the same file. Since scan
@@ -407,11 +408,12 @@ private Optional checkKeyRange(
private Function conflictException(
String baseCommitUser,
List baseEntries,
- List deltaEntries) {
+ List deltaEntries,
+ CommitKind commitKind) {
return e -> {
Pair conflictException =
createConflictException(
- "File deletion conflicts detected! Give up committing.",
+ fileDeletionConflictMessage(commitKind),
baseCommitUser,
baseEntries,
deltaEntries,
@@ -421,6 +423,22 @@ private Function conflictException(
};
}
+ private String fileDeletionConflictMessage(CommitKind commitKind) {
+ String message = "File deletion conflicts detected! Give up committing.";
+ if (deletionVectorsEnabled
+ && bucketMode == BucketMode.BUCKET_UNAWARE
+ && commitKind == CommitKind.COMPACT) {
+ return message
+ + " The compact commit conflicts with changes to its input files after they "
+ + "were read. On a deletion-vector table, concurrent deletes or updates may "
+ + "have changed deletion vectors on those files; committing could then drop "
+ + "newer deletion vectors and restore deleted rows. Another job may also have "
+ + "compacted or removed the same files. Please retry the compaction after the "
+ + "concurrent operations have finished.";
+ }
+ return message;
+ }
+
private Optional checkDeleteInEntries(
Collection mergedEntries,
Function exceptionFunction) {
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
index b66cca67e329..03ff5264479b 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
@@ -317,6 +317,14 @@ public int filterAndCommitMultiple(List committables) {
return filterAndCommitMultiple(committables, true);
}
+ public List filterCommitted(List committables) {
+ List sortedCommittables =
+ committables.stream()
+ .sorted(Comparator.comparingLong(ManifestCommittable::identifier))
+ .collect(Collectors.toList());
+ return commit.filterCommitted(sortedCommittables);
+ }
+
public int filterAndCommitMultiple(
List committables, boolean checkAppendFiles) {
List sortedCommittables =
diff --git a/paimon-core/src/test/java/org/apache/paimon/append/SortCompactCommitMessageRewriterTest.java b/paimon-core/src/test/java/org/apache/paimon/append/SortCompactCommitMessageRewriterTest.java
new file mode 100644
index 000000000000..251330e17690
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/append/SortCompactCommitMessageRewriterTest.java
@@ -0,0 +1,1311 @@
+/*
+ * 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;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.TestAppendFileStore;
+import org.apache.paimon.TestKeyValueGenerator;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileIOFinder;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+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.FileSource;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.SchemaUtils;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.sink.BatchTableCommit;
+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.types.RowType;
+import org.apache.paimon.utils.TraceableFileIO;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.io.DataFileTestUtils.newFile;
+import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link SortCompactCommitMessageRewriter}. */
+public class SortCompactCommitMessageRewriterTest {
+
+ @TempDir java.nio.file.Path tempDir;
+
+ private static DataFileMeta asCompactAfter(DataFileMeta file) {
+ return file.assignFileSource(FileSource.COMPACT);
+ }
+
+ @Test
+ public void testRewriteToCompactMessages() throws Exception {
+ FileStoreTable table = createAppendTable(Collections.emptyMap());
+
+ DataFileMeta old0 = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta old1 = newFile("data-1.orc", 0, 101, 200, 200);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 200, 200);
+
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Arrays.asList(old0, old1))
+ .build();
+
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ List result =
+ new SortCompactCommitMessageRewriter(table, 0L, Collections.singletonList(split))
+ .rewrite(Collections.singletonList(written));
+
+ assertThat(result).hasSize(1);
+ CommitMessageImpl compact = (CommitMessageImpl) result.get(0);
+ assertThat(compact.newFilesIncrement().isEmpty()).isTrue();
+ CompactIncrement ci = compact.compactIncrement();
+ assertThat(ci.compactBefore()).containsExactly(old0, old1);
+ assertThat(ci.compactAfter()).containsExactly(asCompactAfter(sorted));
+ assertThat(ci.changelogFiles()).isEmpty();
+ }
+
+ @Test
+ public void testDetectBatchCompactCommitSucceeded() throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, Collections.emptyMap());
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ CommitMessageImpl written =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-0.orc"));
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ List compactMessages = rewriter.rewrite(Collections.singletonList(written));
+ assertThat(rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages))
+ .isFalse();
+
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(compactMessages);
+ }
+
+ assertThat(rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages))
+ .isTrue();
+ assertThat(table.snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+ assertThat(table.snapshotManager().latestSnapshot().commitIdentifier())
+ .isEqualTo(BatchWriteBuilder.COMMIT_IDENTIFIER);
+ }
+
+ @Test
+ public void testDetectBatchCompactCommitSucceededWhenNewerAppendExists() throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, Collections.emptyMap());
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ CommitMessageImpl written =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-0.orc"));
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+
+ List compactMessages = rewriter.rewrite(Collections.singletonList(written));
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(compactMessages);
+ }
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("concurrent.orc")));
+
+ assertThat(table.snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.APPEND);
+ assertThat(rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages))
+ .isTrue();
+ }
+
+ @Test
+ public void testDetectBatchCompactCommitSucceededAcrossExpiredSnapshotGap() throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, Collections.emptyMap());
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ CommitMessageImpl written =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-0.orc"));
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ List compactMessages = rewriter.rewrite(Collections.singletonList(written));
+
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("filler.orc")));
+ long fillerSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(compactMessages);
+ }
+ table.snapshotManager().deleteSnapshot(fillerSnapshotId);
+
+ assertThat(rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages))
+ .isTrue();
+ }
+
+ @Test
+ public void testDetectBatchCompactCommitSucceededWhenCompactSnapshotExpired() throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, Collections.emptyMap());
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ CommitMessageImpl written =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-0.orc"));
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ List compactMessages = rewriter.rewrite(Collections.singletonList(written));
+
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(compactMessages);
+ }
+ long compactSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("concurrent.orc")));
+ table.snapshotManager().deleteSnapshot(compactSnapshotId);
+
+ assertThat(table.snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.APPEND);
+ assertThat(rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages))
+ .isTrue();
+ }
+
+ @Test
+ public void testDetectBatchCompactCommitSucceededWhenPartialOutputReplaced() throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, Collections.emptyMap());
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 1, Collections.singletonList("data-1.orc")));
+
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+ DataFileMeta oldBucket0 = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta oldBucket1 = newFile("data-1.orc", 0, 0, 100, 100);
+ CommitMessageImpl written0 =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-0.orc"));
+ CommitMessageImpl written1 =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 1, Collections.singletonList("sorted-1.orc"));
+ DataSplit split0 =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(oldBucket0))
+ .build();
+ DataSplit split1 =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(1)
+ .withBucketPath("bucket-1")
+ .withDataFiles(Collections.singletonList(oldBucket1))
+ .build();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Arrays.asList(split0, split1));
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ List compactMessages = rewriter.rewrite(Arrays.asList(written0, written1));
+
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(compactMessages);
+ }
+ long compactSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta mergedBucket0 = newFile("merged-0.orc", 0, 0, 100, 100);
+ CommitMessageImpl partialCompact =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ DataIncrement.emptyIncrement(),
+ new CompactIncrement(
+ Collections.singletonList(
+ asCompactAfter(newFile("sorted-0.orc", 0, 0, 100, 100))),
+ Collections.singletonList(asCompactAfter(mergedBucket0)),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList()));
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(Collections.singletonList(partialCompact));
+ }
+ table.snapshotManager().deleteSnapshot(compactSnapshotId);
+
+ assertThat(rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages))
+ .isTrue();
+ }
+
+ @Test
+ public void testDetectBatchCompactCommitSucceededIgnoresUnrelatedCompact() throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, Collections.emptyMap());
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc")));
+
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ CommitMessageImpl written =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-0.orc"));
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ List compactMessages = rewriter.rewrite(Collections.singletonList(written));
+
+ DataFileMeta otherOld = newFile("data-1.orc", 0, 101, 200, 200);
+ CommitMessageImpl otherWritten =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-1.orc"));
+ DataSplit otherSplit =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(otherOld))
+ .build();
+ List otherCompactMessages =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(otherSplit))
+ .rewrite(Collections.singletonList(otherWritten));
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(otherCompactMessages);
+ }
+
+ assertThat(rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages))
+ .isFalse();
+ }
+
+ @Test
+ public void testRewriteMultipleBuckets() throws Exception {
+ FileStoreTable table = createAppendTable(Collections.emptyMap());
+
+ DataFileMeta oldBucket0 = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta oldBucket1 = newFile("data-1.orc", 0, 0, 100, 100);
+ DataFileMeta sortedBucket0 = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataFileMeta sortedBucket1 = newFile("sorted-1.orc", 0, 0, 100, 100);
+
+ DataSplit split0 =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(oldBucket0))
+ .build();
+ DataSplit split1 =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(1)
+ .withBucketPath("bucket-1")
+ .withDataFiles(Collections.singletonList(oldBucket1))
+ .build();
+
+ CommitMessageImpl written0 =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sortedBucket0),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+ CommitMessageImpl written1 =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 1,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sortedBucket1),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ List result =
+ new SortCompactCommitMessageRewriter(table, 0L, Arrays.asList(split0, split1))
+ .rewrite(Arrays.asList(written0, written1));
+
+ assertThat(result).hasSize(2);
+ for (CommitMessage message : result) {
+ CommitMessageImpl compact = (CommitMessageImpl) message;
+ assertThat(compact.newFilesIncrement().isEmpty()).isTrue();
+ assertThat(compact.compactIncrement().compactBefore()).hasSize(1);
+ assertThat(compact.compactIncrement().compactAfter()).hasSize(1);
+ }
+ CommitMessageImpl compact0 =
+ (CommitMessageImpl) result.stream().filter(m -> m.bucket() == 0).findFirst().get();
+ assertThat(compact0.compactIncrement().compactBefore()).containsExactly(oldBucket0);
+ assertThat(compact0.compactIncrement().compactAfter())
+ .containsExactly(asCompactAfter(sortedBucket0));
+ CommitMessageImpl compact1 =
+ (CommitMessageImpl) result.stream().filter(m -> m.bucket() == 1).findFirst().get();
+ assertThat(compact1.compactIncrement().compactBefore()).containsExactly(oldBucket1);
+ assertThat(compact1.compactIncrement().compactAfter())
+ .containsExactly(asCompactAfter(sortedBucket1));
+ }
+
+ @Test
+ public void testRewriteKeepsPlannedDeletesIndependentFromOutputBuckets() throws Exception {
+ FileStoreTable table = createAppendTable(Collections.emptyMap());
+
+ DataFileMeta oldBucket0 = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta sortedBucket1 = newFile("sorted-1.orc", 0, 0, 100, 100);
+
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withTotalBuckets(2)
+ .withDataFiles(Collections.singletonList(oldBucket0))
+ .build();
+
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 1,
+ 2,
+ new DataIncrement(
+ Collections.singletonList(sortedBucket1),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ List result =
+ new SortCompactCommitMessageRewriter(table, 0L, Collections.singletonList(split))
+ .rewrite(Collections.singletonList(written));
+
+ assertThat(result).hasSize(2);
+ CommitMessageImpl compactDelete =
+ (CommitMessageImpl) result.stream().filter(m -> m.bucket() == 0).findFirst().get();
+ assertThat(compactDelete.totalBuckets()).isEqualTo(2);
+ assertThat(compactDelete.compactIncrement().compactBefore()).containsExactly(oldBucket0);
+ assertThat(compactDelete.compactIncrement().compactAfter()).isEmpty();
+
+ CommitMessageImpl compactAdd =
+ (CommitMessageImpl) result.stream().filter(m -> m.bucket() == 1).findFirst().get();
+ assertThat(compactAdd.totalBuckets()).isEqualTo(2);
+ assertThat(compactAdd.compactIncrement().compactBefore()).isEmpty();
+ assertThat(compactAdd.compactIncrement().compactAfter())
+ .containsExactly(asCompactAfter(sortedBucket1));
+ }
+
+ @Test
+ public void testRewriteWithDeletionVectors() throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+
+ // write deletion vectors for two old files
+ Map> dvs = new HashMap<>();
+ dvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ dvs.put("data-1.orc", Arrays.asList(2, 4, 6));
+ CommitMessageImpl dvMessage = store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, dvs);
+ store.commit(dvMessage);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old0 = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta old1 = newFile("data-1.orc", 0, 101, 200, 200);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 200, 200);
+
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Arrays.asList(old0, old1))
+ .build();
+
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ List result =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split))
+ .rewrite(Collections.singletonList(written));
+
+ CommitMessageImpl compact = (CommitMessageImpl) result.get(0);
+ // all old files are removed, so their DV index entries must be cleaned up
+ assertThat(compact.compactIncrement().deletedIndexFiles()).isNotEmpty();
+ assertThat(compact.compactIncrement().newIndexFiles()).isEmpty();
+ assertThat(compact.compactIncrement().compactBefore()).containsExactly(old0, old1);
+ assertThat(compact.compactIncrement().compactAfter())
+ .containsExactly(asCompactAfter(sorted));
+ assertThat(compact.newFilesIncrement().isEmpty()).isTrue();
+ }
+
+ @Test
+ public void testRewriteFailsWhenConcurrentDeletionVectorAdded() throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+
+ Map> concurrentDvs = new HashMap<>();
+ concurrentDvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ store.commit(store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, concurrentDvs));
+
+ assertThatThrownBy(() -> rewriter.rewrite(Collections.singletonList(written)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("deletion vectors on input files changed")
+ .hasMessageContaining("restore deleted rows")
+ .hasMessageContaining("Please retry");
+ }
+
+ @Test
+ public void testRewriteFailsWhenConcurrentDeletionVectorReplaced() throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ Map> baseDvs = new HashMap<>();
+ baseDvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ store.commit(store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, baseDvs));
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+
+ Map> concurrentDvs = new HashMap<>();
+ concurrentDvs.put("data-0.orc", Arrays.asList(2, 4, 6));
+ commitUnawareDeletionVectors(table, concurrentDvs);
+
+ assertThatThrownBy(() -> rewriter.rewrite(Collections.singletonList(written)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("deletion vectors on input files changed")
+ .hasMessageContaining("restore deleted rows")
+ .hasMessageContaining("Please retry")
+ .hasMessageContaining("baseDv=")
+ .hasMessageContaining("latestDv=");
+ }
+
+ @Test
+ public void testRewriteFailsWhenConcurrentDeletionVectorAddedAfterBaseExpired()
+ throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ // Capture known-empty base DV state before the base snapshot expires.
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+
+ Map> concurrentDvs = new HashMap<>();
+ concurrentDvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ store.commit(store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, concurrentDvs));
+ table.snapshotManager().deleteSnapshot(baseSnapshotId);
+
+ assertThatThrownBy(() -> rewriter.rewrite(Collections.singletonList(written)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("deletion vectors on input files changed")
+ .hasMessageContaining("restore deleted rows")
+ .hasMessageContaining("Please retry");
+ }
+
+ @Test
+ public void testRewriteFailsWhenConcurrentDeletionVectorAddedWithCapturedEmptyMetadata()
+ throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ SortCompactPlanMetadata planMetadata =
+ SortCompactPlanMetadata.capture(
+ table, baseSnapshotId, Collections.singletonList(split));
+ assertThat(planMetadata.baseSnapshotCaptured()).isTrue();
+
+ Map> concurrentDvs = new HashMap<>();
+ concurrentDvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ store.commit(store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, concurrentDvs));
+ table.snapshotManager().deleteSnapshot(baseSnapshotId);
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split), planMetadata);
+
+ assertThatThrownBy(() -> rewriter.rewrite(Collections.singletonList(written)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("deletion vectors on input files changed")
+ .hasMessageContaining("restore deleted rows")
+ .hasMessageContaining("Please retry");
+ }
+
+ @Test
+ public void testRewriteInputOnlyGroupWithDeletionVectors() throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+
+ Map> dvs = new HashMap<>();
+ dvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ CommitMessageImpl dvMessage = store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, dvs);
+ store.commit(dvMessage);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ List result =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split))
+ .rewrite(Collections.emptyList());
+
+ assertThat(result).hasSize(1);
+ CommitMessageImpl compact = (CommitMessageImpl) result.get(0);
+ assertThat(compact.newFilesIncrement().isEmpty()).isTrue();
+ assertThat(compact.compactIncrement().compactBefore()).containsExactly(old);
+ assertThat(compact.compactIncrement().compactAfter()).isEmpty();
+ assertThat(compact.compactIncrement().deletedIndexFiles()).isNotEmpty();
+ assertThat(compact.compactIncrement().newIndexFiles()).isEmpty();
+ }
+
+ @Test
+ public void testAbortCompactMessagesCleansUpNewDeletionVectorFiles() throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+
+ // Two old data files sharing a single DV index file.
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc")));
+ Map> dvs = new HashMap<>();
+ dvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ dvs.put("data-1.orc", Arrays.asList(2, 4, 6));
+ store.commit(store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, dvs));
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ // Plan sort compact for only data-0; data-1 stays, so its DV must be rewritten to a new
+ // index file by dvMaintainer.persist() during rewrite.
+ DataFileMeta old0 = newFile("data-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old0))
+ .build();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ List compactMessages = rewriter.rewrite(Collections.emptyList());
+
+ assertThat(compactMessages).hasSize(1);
+ CommitMessageImpl compact = (CommitMessageImpl) compactMessages.get(0);
+ assertThat(compact.compactIncrement().newIndexFiles())
+ .as("new DV index file rewriting data-1's deletion vector")
+ .hasSize(1);
+ assertThat(compact.compactIncrement().deletedIndexFiles())
+ .as("old shared DV index file marked for deletion")
+ .hasSize(1);
+
+ IndexFileMeta newDvFile = compact.compactIncrement().newIndexFiles().get(0);
+ IndexFileMeta oldSharedDvFile = compact.compactIncrement().deletedIndexFiles().get(0);
+ IndexPathFactory indexPathFactory =
+ table.store().pathFactory().indexFileFactory(BinaryRow.EMPTY_ROW, UNAWARE_BUCKET);
+ Path newDvPath = indexPathFactory.toPath(newDvFile);
+ Path oldSharedDvPath = indexPathFactory.toPath(oldSharedDvFile);
+ assertThat(table.fileIO().exists(newDvPath)).isTrue();
+ assertThat(table.fileIO().exists(oldSharedDvPath)).isTrue();
+
+ rewriter.abortCompactMessages(compactMessages);
+
+ // The new DV file is only referenced by the uncommitted compact messages, so abort cleans
+ // it up to avoid orphaned index files on retry.
+ assertThat(table.fileIO().exists(newDvPath)).isFalse();
+ // The old shared DV file is still referenced by the latest snapshot (the compact did not
+ // commit), so abort must not delete it.
+ assertThat(table.fileIO().exists(oldSharedDvPath)).isTrue();
+ }
+
+ @Test
+ public void testAbortWrittenMessagesCleansUpSortedDataFiles() throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, Collections.emptyMap());
+ store.commit(
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-0.orc")));
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ CommitMessageImpl written =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("sorted-0.orc"));
+ DataFileMeta sorted = written.newFilesIncrement().newFiles().get(0);
+ Path sortedPath =
+ table.store()
+ .pathFactory()
+ .createDataFilePathFactory(BinaryRow.EMPTY_ROW, 0)
+ .toPath(sorted);
+ assertThat(table.fileIO().exists(sortedPath)).isTrue();
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ rewriter.abortWrittenMessages(Collections.singletonList(written));
+
+ assertThat(table.fileIO().exists(sortedPath)).isFalse();
+ }
+
+ @Test
+ public void testRewritePartialMessagesMustBeMerged() throws Exception {
+ FileStoreTable table = createAppendTable(Collections.emptyMap());
+
+ DataFileMeta oldBucket0 = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta oldBucket1 = newFile("data-1.orc", 0, 0, 100, 100);
+ DataFileMeta sortedBucket0 = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataFileMeta sortedBucket1 = newFile("sorted-1.orc", 0, 0, 100, 100);
+
+ DataSplit split0 =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(oldBucket0))
+ .build();
+ DataSplit split1 =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(1)
+ .withBucketPath("bucket-1")
+ .withDataFiles(Collections.singletonList(oldBucket1))
+ .build();
+
+ CommitMessageImpl writtenBucket0 =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sortedBucket0),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+ CommitMessageImpl writtenBucket1 =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 1,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sortedBucket1),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(table, 0L, Arrays.asList(split0, split1));
+
+ // Rewriting partial outputs separately would duplicate compactBefore for every commit.
+ List partial0 = rewriter.rewrite(Collections.singletonList(writtenBucket0));
+ List partial1 = rewriter.rewrite(Collections.singletonList(writtenBucket1));
+ assertThat(partial0).hasSize(2);
+ assertThat(partial1).hasSize(2);
+
+ List merged =
+ rewriter.rewrite(Arrays.asList(writtenBucket0, writtenBucket1));
+ assertThat(merged).hasSize(2);
+ for (CommitMessage message : merged) {
+ CommitMessageImpl compact = (CommitMessageImpl) message;
+ assertThat(compact.newFilesIncrement().isEmpty()).isTrue();
+ assertThat(compact.compactIncrement().compactBefore()).hasSize(1);
+ assertThat(compact.compactIncrement().compactAfter()).hasSize(1);
+ }
+ }
+
+ @Test
+ public void testRewriteUsesCapturedBaseSnapshotMetadata() throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+
+ Map> dvs = new HashMap<>();
+ dvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ CommitMessageImpl dvMessage = store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, dvs);
+ store.commit(dvMessage);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split));
+ table.snapshotManager().deleteSnapshot(baseSnapshotId);
+
+ List result = rewriter.rewrite(Collections.singletonList(written));
+
+ CommitMessageImpl compact = (CommitMessageImpl) result.get(0);
+ assertThat(compact.compactIncrement().deletedIndexFiles()).isNotEmpty();
+ assertThat(compact.compactIncrement().compactBefore()).containsExactly(old);
+ assertThat(compact.compactIncrement().compactAfter())
+ .containsExactly(asCompactAfter(sorted));
+ }
+
+ @Test
+ public void testPlanMetadataRoundTripSerialization() throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+
+ Map> dvs = new HashMap<>();
+ dvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ CommitMessageImpl dvMessage = store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, dvs);
+ store.commit(dvMessage);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(
+ Collections.singletonList(newFile("data-0.orc", 0, 0, 100, 100)))
+ .build();
+
+ SortCompactPlanMetadata captured =
+ SortCompactPlanMetadata.capture(
+ table, baseSnapshotId, Collections.singletonList(split));
+ SortCompactPlanMetadata restored;
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(captured);
+ try (ObjectInputStream ois =
+ new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
+ restored = (SortCompactPlanMetadata) ois.readObject();
+ }
+ }
+
+ Map> dvEntries = new HashMap<>();
+ captured.copyInto(dvEntries);
+ Map> restoredDvEntries = new HashMap<>();
+ restored.copyInto(restoredDvEntries);
+
+ assertThat(restoredDvEntries).isEqualTo(dvEntries);
+ assertThat(captured.baseSnapshotCaptured()).isTrue();
+ assertThat(restored.baseSnapshotCaptured()).isTrue();
+ }
+
+ @Test
+ public void testRewriteWithoutCapturedMetadataSkipsDvCleanupWhenBaseSnapshotExpired()
+ throws Exception {
+ TestAppendFileStore store =
+ createAppendStore(
+ tempDir,
+ Collections.singletonMap(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"));
+
+ Map> dvs = new HashMap<>();
+ dvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ CommitMessageImpl dvMessage = store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, dvs);
+ store.commit(dvMessage);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ long baseSnapshotId = table.snapshotManager().latestSnapshotId();
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ SortCompactPlanMetadata planMetadata =
+ SortCompactPlanMetadata.capture(
+ table, baseSnapshotId, Collections.singletonList(split));
+ table.snapshotManager().deleteSnapshot(baseSnapshotId);
+
+ List withoutCapturedMetadata =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, Collections.singletonList(split))
+ .rewrite(Collections.singletonList(written));
+ assertThat(
+ ((CommitMessageImpl) withoutCapturedMetadata.get(0))
+ .compactIncrement()
+ .deletedIndexFiles())
+ .isEmpty();
+
+ List withCapturedMetadata =
+ new SortCompactCommitMessageRewriter(
+ table,
+ baseSnapshotId,
+ Collections.singletonList(split),
+ planMetadata)
+ .rewrite(Collections.singletonList(written));
+ assertThat(
+ ((CommitMessageImpl) withCapturedMetadata.get(0))
+ .compactIncrement()
+ .deletedIndexFiles())
+ .isNotEmpty();
+ }
+
+ @Test
+ public void testRewriteRejectsInlineCompactionOutput() throws Exception {
+ FileStoreTable table = createAppendTable(Collections.emptyMap());
+
+ DataFileMeta old = newFile("data-0.orc", 0, 0, 100, 100);
+ DataFileMeta l0File = newFile("l0-0.orc", 0, 0, 100, 100);
+ DataFileMeta compactedFile = newFile("compacted-0.orc", 0, 0, 100, 100);
+
+ DataSplit split =
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(old))
+ .build();
+
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(l0File),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ new CompactIncrement(
+ Collections.singletonList(old),
+ Collections.singletonList(compactedFile),
+ Collections.emptyList()));
+
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(table, 0L, Collections.singletonList(split));
+
+ assertThatThrownBy(() -> rewriter.rewrite(Collections.singletonList(written)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("inline compaction changes");
+ }
+
+ private FileStoreTable createAppendTable(Map dynamicOptions) throws Exception {
+ TestAppendFileStore store = createAppendStore(tempDir, dynamicOptions);
+ return FileStoreTableFactory.create(store.fileIO(), store.options().path(), store.schema());
+ }
+
+ /**
+ * Commit additional deletion vectors for an unaware-bucket append table, correctly deleting the
+ * previous index file when replacing an existing DV.
+ */
+ private void commitUnawareDeletionVectors(
+ FileStoreTable table, Map> dataFileToPositions) throws Exception {
+ BaseAppendDeleteFileMaintainer maintainer =
+ BaseAppendDeleteFileMaintainer.forUnawareAppend(
+ table.store().newIndexFileHandler(),
+ table.snapshotManager().latestSnapshot(),
+ BinaryRow.EMPTY_ROW);
+ for (Map.Entry> entry : dataFileToPositions.entrySet()) {
+ DeletionVector deletionVector = new BitmapDeletionVector();
+ for (Integer pos : entry.getValue()) {
+ deletionVector.delete(pos);
+ }
+ maintainer.notifyNewDeletionVector(entry.getKey(), deletionVector);
+ }
+
+ List newIndexFiles = new ArrayList<>();
+ List deletedIndexFiles = new ArrayList<>();
+ for (IndexManifestEntry entry : maintainer.persist()) {
+ if (entry.kind() == FileKind.ADD) {
+ newIndexFiles.add(entry.indexFile());
+ } else {
+ deletedIndexFiles.add(entry.indexFile());
+ }
+ }
+
+ CommitMessage message =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ UNAWARE_BUCKET,
+ null,
+ new DataIncrement(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ newIndexFiles,
+ deletedIndexFiles),
+ CompactIncrement.emptyIncrement());
+ try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(Collections.singletonList(message));
+ }
+ }
+
+ private TestAppendFileStore createAppendStore(
+ java.nio.file.Path tempDir, Map dynamicOptions) throws Exception {
+ String root = TraceableFileIO.SCHEME + "://" + tempDir.toString();
+ Path path = new Path(tempDir.toUri());
+ FileIO fileIO = FileIOFinder.find(new Path(root));
+ SchemaManager schemaManage = new SchemaManager(new LocalFileIO(), path);
+
+ Map options = new HashMap<>(dynamicOptions);
+ options.put(CoreOptions.PATH.key(), root);
+ TableSchema tableSchema =
+ SchemaUtils.forceCommit(
+ schemaManage,
+ new Schema(
+ TestKeyValueGenerator.DEFAULT_ROW_TYPE.getFields(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ options,
+ null));
+ return new TestAppendFileStore(
+ fileIO,
+ schemaManage,
+ new CoreOptions(options),
+ tableSchema,
+ RowType.of(),
+ RowType.of(),
+ TestKeyValueGenerator.DEFAULT_ROW_TYPE,
+ (new Path(root)).getName());
+ }
+}
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 cfc2963d085d..b2471e30ac52 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
@@ -297,6 +297,41 @@ public void testConflictDeletionWithDV() {
}
}
+ @Test
+ void testCompactDeletionConflictWithDvHasActionableMessage() {
+ ConflictDetection detection =
+ new ConflictDetection(
+ "test-table",
+ "test-user",
+ RowType.of(),
+ null,
+ null,
+ BucketMode.BUCKET_UNAWARE,
+ true,
+ false,
+ false,
+ null,
+ null,
+ null);
+
+ Optional exception =
+ detection.checkConflicts(
+ snapshot(1),
+ Collections.singletonList(createFileEntry("existing", ADD)),
+ Collections.singletonList(createFileEntry("missing", DELETE)),
+ Collections.emptyList(),
+ null,
+ Snapshot.CommitKind.COMPACT);
+
+ assertThat(exception).isPresent();
+ assertThat(exception.get())
+ .hasMessageContaining("File deletion conflicts detected")
+ .hasMessageContaining("compact commit conflicts with changes to its input files")
+ .hasMessageContaining("deletion vectors")
+ .hasMessageContaining("restore deleted rows")
+ .hasMessageContaining("Please retry the compaction");
+ }
+
private SimpleFileEntry createFileEntry(String fileName, FileKind kind) {
return new SimpleFileEntry(
kind,
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
index 501b6aed16e4..a7858ed9ec47 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
@@ -140,7 +140,10 @@ public CompactAction withFullCompaction(Boolean fullCompaction) {
@Override
public void build() throws Exception {
- buildImpl();
+ if (!buildImpl()) {
+ // empty input: no-op topology so procedure execution succeeds without commit
+ env.fromSequence(0, 0).name("Nothing to Sort Compact").sinkTo(new DiscardingSink<>());
+ }
}
protected boolean buildImpl() throws Exception {
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/SortCompactAction.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/SortCompactAction.java
index 289802e2ba6b..23289b3c42d0 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/SortCompactAction.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/SortCompactAction.java
@@ -25,8 +25,11 @@
import org.apache.paimon.flink.sorter.TableSortInfo;
import org.apache.paimon.flink.sorter.TableSorter;
import org.apache.paimon.flink.source.FlinkSourceBuilder;
+import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.table.BucketMode;
import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.configuration.ExecutionOptions;
@@ -38,6 +41,7 @@
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -55,18 +59,22 @@ public SortCompactAction(
String tableName,
Map catalogConfig,
Map tableConf) {
- super(database, tableName, catalogConfig, tableConf);
+ // Time-travel scan options in tableConf would switch tableSchema to a historical
+ // version during table.copy() in the parent constructor. Sort compact always plans
+ // against the latest snapshot and must read/write with the latest schema.
+ super(database, tableName, catalogConfig, withoutInheritedScanOptions(tableConf));
table = table.copy(Collections.singletonMap(CoreOptions.WRITE_ONLY.key(), "true"));
}
@Override
public void run() throws Exception {
- build();
- execute("Sort Compact Job");
+ if (buildImpl()) {
+ execute("Sort Compact Job");
+ }
}
@Override
- public void build() throws Exception {
+ protected boolean buildImpl() throws Exception {
// only support batch sort yet
if (env.getConfiguration().get(ExecutionOptions.RUNTIME_MODE)
!= RuntimeExecutionMode.BATCH) {
@@ -80,13 +88,47 @@ public void build() throws Exception {
throw new UnsupportedOperationException("Data Evolution table cannot be sorted!");
}
- if (fileStoreTable.bucketMode() != BucketMode.BUCKET_UNAWARE
- && fileStoreTable.bucketMode() != BucketMode.HASH_DYNAMIC) {
- throw new IllegalArgumentException("Sort Compact only supports bucket=-1 yet.");
+ if (fileStoreTable.coreOptions().rowTrackingEnabled()) {
+ throw new UnsupportedOperationException(
+ "Sort compact is unsupported for row tracking tables.");
+ }
+
+ if (!fileStoreTable.primaryKeys().isEmpty()) {
+ throw new UnsupportedOperationException(
+ "Sort compact only supports append tables without primary keys.");
+ }
+
+ if (fileStoreTable.bucketMode() != BucketMode.BUCKET_UNAWARE) {
+ throw new IllegalArgumentException(
+ "Sort compact only supports bucket-unaware append tables.");
+ }
+
+ // Capture the base snapshot and the planned input splits. The old files in these splits
+ // become compactBefore of the compact commit, so that sort compact is committed as a
+ // normal COMPACT commit (instead of OVERWRITE) and does not drop data appended
+ // concurrently since the base snapshot.
+ PartitionPredicate partitionPredicate = getPartitionPredicate();
+ SnapshotReader snapshotReader = fileStoreTable.newSnapshotReader();
+ if (partitionPredicate != null) {
+ snapshotReader.withPartitionFilter(partitionPredicate);
+ }
+ SnapshotReader.Plan plan = snapshotReader.read();
+ Long baseSnapshotId = plan.snapshotId();
+ List dataSplits = plan.dataSplits();
+ if (dataSplits.isEmpty()) {
+ // empty table or no matching partitions: no-op job with no commit
+ return false;
}
+
+ // Pin the source to the captured base snapshot so that the sort compact reads exactly
+ // the planned data, while the sink stays write-only. Clear mutually exclusive scan
+ // options inherited from the table and set scan.mode=from-snapshot explicitly.
+ FileStoreTable sourceTable =
+ fileStoreTable.copyWithoutTimeTravel(pinnedSnapshotSourceOptions(baseSnapshotId));
+
Map tableConfig = fileStoreTable.options();
FlinkSourceBuilder sourceBuilder =
- new FlinkSourceBuilder(fileStoreTable)
+ new FlinkSourceBuilder(sourceTable)
.sourceName(
ObjectIdentifier.of(
catalogName,
@@ -94,7 +136,7 @@ public void build() throws Exception {
identifier.getObjectName())
.asSummaryString());
- sourceBuilder.partitionPredicate(getPartitionPredicate());
+ sourceBuilder.partitionPredicate(partitionPredicate);
String scanParallelism = tableConfig.get(FlinkConnectorOptions.SCAN_PARALLELISM.key());
if (scanParallelism != null) {
@@ -137,9 +179,10 @@ public void build() throws Exception {
new SortCompactSinkBuilder(fileStoreTable)
.forCompact(true)
+ .withSortCompactInput(baseSnapshotId == null ? 0L : baseSnapshotId, dataSplits)
.forRowData(sorter.sort())
- .overwrite()
.build();
+ return true;
}
public SortCompactAction withOrderStrategy(String sortStrategy) {
@@ -155,4 +198,35 @@ public SortCompactAction withOrderColumns(List orderColumns) {
this.orderColumns = orderColumns.stream().map(String::trim).collect(Collectors.toList());
return this;
}
+
+ private static Map withoutInheritedScanOptions(Map tableConf) {
+ Map options = new HashMap<>(tableConf);
+ clearInheritedScanOptions(options);
+ return options;
+ }
+
+ private static void clearInheritedScanOptions(Map options) {
+ options.put(CoreOptions.SCAN_VERSION.key(), null);
+ options.put(CoreOptions.SCAN_TIMESTAMP_MILLIS.key(), null);
+ options.put(CoreOptions.SCAN_TIMESTAMP.key(), null);
+ options.put(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(), null);
+ options.put(CoreOptions.SCAN_CREATION_TIME_MILLIS.key(), null);
+ options.put(CoreOptions.SCAN_TAG_NAME.key(), null);
+ options.put(CoreOptions.SCAN_WATERMARK.key(), null);
+ options.put(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key(), null);
+ options.put(CoreOptions.INCREMENTAL_BETWEEN.key(), null);
+ options.put(CoreOptions.INCREMENTAL_TO_AUTO_TAG.key(), null);
+ options.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), null);
+ options.put(CoreOptions.SCAN_MODE.key(), null);
+ }
+
+ private static Map pinnedSnapshotSourceOptions(Long snapshotId) {
+ Map options = new HashMap<>();
+ clearInheritedScanOptions(options);
+ options.put(
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ String.valueOf(snapshotId == null ? 0L : snapshotId));
+ options.put(CoreOptions.SCAN_MODE.key(), CoreOptions.StartupMode.FROM_SNAPSHOT.toString());
+ return options;
+ }
}
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSink.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSink.java
index b323301c7933..1111ef1d087a 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSink.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSink.java
@@ -90,6 +90,11 @@ public FlinkSink(FileStoreTable table, boolean ignorePreviousFiles) {
this.ignorePreviousFiles = ignorePreviousFiles;
}
+ @Nullable
+ protected StoreSinkWrite.Provider writeProviderOverride() {
+ return null;
+ }
+
void setBlobDescriptorReaderFactory(UriReaderFactory uriReaderFactory) {
this.blobDescriptorReaderFactory = uriReaderFactory;
}
@@ -137,17 +142,19 @@ public DataStream doWrite(
boolean isStreaming = isStreaming(input);
boolean writeOnly = table.coreOptions().writeOnly();
- StoreSinkWrite.Provider writeProvider =
- StoreSinkWrite.createWriteProvider(
- table,
- env.getCheckpointConfig(),
- isStreaming,
- ignorePreviousFiles,
- hasSinkMaterializer(input));
+ StoreSinkWrite.Provider writeProvider = writeProviderOverride();
+ if (writeProvider == null) {
+ writeProvider =
+ StoreSinkWrite.createWriteProvider(
+ table,
+ env.getCheckpointConfig(),
+ isStreaming,
+ ignorePreviousFiles,
+ hasSinkMaterializer(input));
+ }
writeProvider =
StoreSinkWrite.withBlobDescriptorReaderFactory(
writeProvider, blobDescriptorReaderFactory);
-
SingleOutputStreamOperator written =
input.transform(
(writeOnly ? WRITER_WRITE_ONLY_NAME : WRITER_NAME) + " : " + table.name(),
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java
index 55ed3cf3a0a8..457f8d3013c5 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java
@@ -90,7 +90,7 @@ public class FlinkSinkBuilder {
private DataStream input;
@Nullable protected Map overwritePartition;
- @Nullable private Integer parallelism;
+ @Nullable protected Integer parallelism;
@Nullable private TableSortInfo tableSortInfo;
@Nullable private UriReaderFactory blobDescriptorReaderFactory;
@@ -377,7 +377,7 @@ private DataStreamSink> buildPostponeBucketSink(DataStream input)
}
}
- private DataStreamSink> buildUnawareBucketSink(DataStream input) {
+ protected DataStreamSink> buildUnawareBucketSink(DataStream input) {
checkArgument(
table.primaryKeys().isEmpty(),
"Unaware bucket mode only works with append-only table for now.");
@@ -395,9 +395,12 @@ private DataStreamSink> buildUnawareBucketSink(DataStream input)
}
}
- return configureBlobDescriptorReaderFactory(
- new RowAppendTableSink(table, overwritePartition, parallelism))
- .sinkFrom(input);
+ return configureBlobDescriptorReaderFactory(createAppendTableSink()).sinkFrom(input);
+ }
+
+ /** Create the {@link RowAppendTableSink} for the unaware bucket mode. */
+ protected RowAppendTableSink createAppendTableSink() {
+ return new RowAppendTableSink(table, overwritePartition, parallelism);
}
private > T configureBlobDescriptorReaderFactory(T sink) {
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactAppendSinkWrite.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactAppendSinkWrite.java
new file mode 100644
index 000000000000..7e47bd1d7124
--- /dev/null
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactAppendSinkWrite.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink;
+
+import org.apache.paimon.memory.MemoryPoolFactory;
+import org.apache.paimon.table.FileStoreTable;
+
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.runtime.io.disk.iomanager.IOManager;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * {@link StoreSinkWrite} for sort compact on bucket-unaware append tables.
+ *
+ * Always uses {@code waitCompaction=false} so sorted output lands in {@code newFilesIncrement}.
+ * The committer rewrites those files into {@code compactAfter}.
+ */
+public class SortCompactAppendSinkWrite extends StoreSinkWriteImpl {
+
+ public SortCompactAppendSinkWrite(
+ FileStoreTable table,
+ String commitUser,
+ StoreSinkWriteState state,
+ IOManager ioManager,
+ boolean ignorePreviousFiles,
+ boolean isStreamingMode,
+ MemoryPoolFactory memoryPoolFactory,
+ @Nullable MetricGroup metricGroup) {
+ super(
+ table,
+ commitUser,
+ state,
+ ioManager,
+ ignorePreviousFiles,
+ false,
+ isStreamingMode,
+ memoryPoolFactory,
+ metricGroup);
+ }
+
+ @Override
+ public List prepareCommit(boolean waitCompaction, long checkpointId)
+ throws IOException {
+ // Batch endInput always passes waitCompaction=true, but sort compact write must not wait
+ // for inline compaction. The committer rewrites append-style newFiles into compactAfter.
+ return super.prepareCommit(false, checkpointId);
+ }
+
+ public static StoreSinkWrite.Provider provider() {
+ return (table, commitUser, state, ioManager, memoryPoolFactory, metricGroup) ->
+ new SortCompactAppendSinkWrite(
+ table,
+ commitUser,
+ state,
+ ioManager,
+ true,
+ false,
+ memoryPoolFactory,
+ metricGroup);
+ }
+}
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactAppendTableSink.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactAppendTableSink.java
new file mode 100644
index 000000000000..22165a0efe75
--- /dev/null
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactAppendTableSink.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink;
+
+import org.apache.paimon.append.SortCompactCommitMessageRewriter;
+import org.apache.paimon.append.SortCompactPlanMetadata;
+import org.apache.paimon.flink.sink.StoreSinkWrite.Provider;
+import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+
+import javax.annotation.Nullable;
+
+import java.util.List;
+
+/**
+ * A {@link RowAppendTableSink} for the sort compact topology of bucket-unaware append tables. It
+ * produces a {@link SortCompactCommitter} so that the written append files are committed as a
+ * {@code COMPACT} snapshot.
+ *
+ * The compact-before files (captured by the sort compact plan) are carried as {@link DataSplit}s
+ * (which are serializable) into the job graph and used at commit time to build the compact commit
+ * messages.
+ *
+ *
Scale note: see {@link SortCompactSinkBuilder#withSortCompactInput(long, List)}.
+ */
+public class SortCompactAppendTableSink extends RowAppendTableSink {
+
+ private static final long serialVersionUID = 1L;
+
+ private final long baseSnapshotId;
+ private final List compactInputSplits;
+ private final SortCompactPlanMetadata planMetadata;
+
+ public SortCompactAppendTableSink(
+ FileStoreTable table,
+ @Nullable Integer parallelism,
+ long baseSnapshotId,
+ List compactInputSplits) {
+ super(table, null, parallelism);
+ this.baseSnapshotId = baseSnapshotId;
+ this.compactInputSplits = compactInputSplits;
+ this.planMetadata =
+ SortCompactPlanMetadata.capture(table, baseSnapshotId, compactInputSplits);
+ }
+
+ @Override
+ protected Provider writeProviderOverride() {
+ return SortCompactAppendSinkWrite.provider();
+ }
+
+ @Override
+ protected Committer.Factory createCommitterFactory() {
+ // If checkpoint is enabled for streaming job, we have to commit new files list even if
+ // they're empty. Otherwise we can't tell if the commit is successful after a restart.
+ return context ->
+ new SortCompactCommitter(
+ table,
+ table.newCommit(context.commitUser())
+ .ignoreEmptyCommit(!context.streamingCheckpointEnabled()),
+ context,
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, compactInputSplits, planMetadata));
+ }
+}
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactCommitter.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactCommitter.java
new file mode 100644
index 000000000000..ab5ef17f3bf2
--- /dev/null
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactCommitter.java
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink;
+
+import org.apache.paimon.append.SortCompactCommitMessageRewriter;
+import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.sink.TableCommit;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * A {@link StoreCommitter} for the sort compact topology. It rewrites the append {@link
+ * CommitMessage}s produced by the sort compact write stage into compact commit messages (via {@link
+ * SortCompactCommitMessageRewriter}) before delegating to the normal {@link StoreCommitter} commit,
+ * so that the resulting snapshot is a {@code COMPACT} snapshot instead of an {@code OVERWRITE}
+ * snapshot.
+ */
+public class SortCompactCommitter extends StoreCommitter {
+
+ private final SortCompactCommitMessageRewriter rewriter;
+
+ public SortCompactCommitter(
+ FileStoreTable table,
+ TableCommit commit,
+ Context context,
+ SortCompactCommitMessageRewriter rewriter) {
+ super(table, commit, context);
+ this.rewriter = rewriter;
+ }
+
+ @Override
+ protected long additionalBytesOut(CommitMessageImpl impl) {
+ return calcTotalFileSize(impl.compactIncrement().compactAfter());
+ }
+
+ @Override
+ protected long additionalRecordsOut(CommitMessageImpl impl) {
+ return calcTotalFileRowCount(impl.compactIncrement().compactAfter());
+ }
+
+ @Override
+ public void commit(List committables)
+ throws IOException, InterruptedException {
+ List writtenMessages = collectWrittenMessages(committables);
+ List rewritten;
+ try {
+ rewritten = rewriteAll(committables);
+ } catch (RuntimeException e) {
+ abortWrittenQuietly(writtenMessages, e);
+ throw e;
+ }
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ try {
+ super.commit(rewritten);
+ } catch (IOException | InterruptedException e) {
+ maybeAbortAfterFailedCommit(writtenMessages, rewritten, snapshotIdBeforeCommit, e);
+ throw e;
+ } catch (RuntimeException e) {
+ maybeAbortAfterFailedCommit(writtenMessages, rewritten, snapshotIdBeforeCommit, e);
+ throw e;
+ }
+ }
+
+ @Override
+ public int filterAndCommit(
+ List globalCommittables,
+ boolean checkAppendFiles,
+ boolean partitionMarkDoneRecoverFromState) {
+ List sortedCommittables =
+ globalCommittables.stream()
+ .sorted(Comparator.comparingLong(ManifestCommittable::identifier))
+ .collect(Collectors.toList());
+ List retryCommittables = commit.filterCommitted(sortedCommittables);
+ if (retryCommittables.isEmpty()) {
+ // Delete-only compact commits are only valid at job end (filterAndCommit with
+ // checkAppendFiles=false, e.g. CommitterOperator#endInput). Recovery paths call
+ // filterAndCommit with checkAppendFiles=true and an empty restored list; treating
+ // that as delete-only would remove all planned input files before writers run.
+ if (!checkAppendFiles
+ && sortedCommittables.isEmpty()
+ && rewriter.hasInput()
+ && !rewriter.isPlannedInputAlreadyCommitted()) {
+ return filterAndCommitDeleteOnly(
+ globalCommittables, checkAppendFiles, partitionMarkDoneRecoverFromState);
+ }
+ commitListeners.notifyCommittable(
+ globalCommittables, partitionMarkDoneRecoverFromState);
+ return 0;
+ }
+
+ List writtenMessages = collectWrittenMessages(retryCommittables);
+ List rewritten;
+ try {
+ rewritten = rewriteAll(retryCommittables);
+ } catch (RuntimeException e) {
+ abortWrittenQuietly(writtenMessages, e);
+ throw e;
+ }
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ int committed;
+ try {
+ committed = commit.filterAndCommitMultiple(rewritten, checkAppendFiles);
+ } catch (RuntimeException e) {
+ maybeAbortAfterFailedCommit(writtenMessages, rewritten, snapshotIdBeforeCommit, e);
+ throw e;
+ }
+ calcNumBytesAndRecordsOut(rewritten);
+ commitListeners.notifyCommittable(globalCommittables, partitionMarkDoneRecoverFromState);
+ return committed;
+ }
+
+ private int filterAndCommitDeleteOnly(
+ List globalCommittables,
+ boolean checkAppendFiles,
+ boolean partitionMarkDoneRecoverFromState) {
+ List rewritten;
+ try {
+ rewritten = rewriteAll(Collections.emptyList());
+ } catch (RuntimeException e) {
+ abortWrittenQuietly(Collections.emptyList(), e);
+ throw e;
+ }
+ if (rewritten.isEmpty()) {
+ return 0;
+ }
+ long snapshotIdBeforeCommit = rewriter.latestSnapshotIdOrZero();
+ int committed;
+ try {
+ committed = commit.filterAndCommitMultiple(rewritten, checkAppendFiles);
+ } catch (RuntimeException e) {
+ maybeAbortAfterFailedCommit(
+ Collections.emptyList(), rewritten, snapshotIdBeforeCommit, e);
+ throw e;
+ }
+ calcNumBytesAndRecordsOut(rewritten);
+ commitListeners.notifyCommittable(globalCommittables, partitionMarkDoneRecoverFromState);
+ return committed;
+ }
+
+ /**
+ * Merge all partial write outputs from multiple committables and rewrite once. Each individual
+ * rewrite would attach the full planned {@code compactBefore}, so committing partial outputs
+ * separately would delete all old files too early.
+ *
+ * This also collapses multiple committables into a single one with the maximum identifier,
+ * which changes the per-identifier deduplication semantics used during job recovery. Sort
+ * compact is a batch job and does not rely on that recovery path, so this is acceptable here.
+ */
+ private List rewriteAll(List committables) {
+ if (committables.isEmpty()) {
+ if (!rewriter.hasInput()) {
+ return committables;
+ }
+
+ // A sort compact is a batch job. Even when all input rows are filtered out (for
+ // example, by deletion vectors), its planned input files still need to be removed.
+ return Collections.singletonList(
+ new ManifestCommittable(
+ Long.MAX_VALUE,
+ null,
+ rewriter.rewrite(Collections.emptyList()),
+ Collections.emptyMap()));
+ }
+
+ List allWrittenMessages = new ArrayList<>();
+ long identifier = Long.MIN_VALUE;
+ Long watermark = null;
+ Map properties = new HashMap<>();
+
+ for (ManifestCommittable committable : committables) {
+ allWrittenMessages.addAll(committable.fileCommittables());
+ identifier = Math.max(identifier, committable.identifier());
+ if (committable.watermark() != null) {
+ watermark =
+ watermark == null
+ ? committable.watermark()
+ : Math.max(watermark, committable.watermark());
+ }
+ properties.putAll(committable.properties());
+ }
+
+ List compactMessages = rewriter.rewrite(allWrittenMessages);
+ return Collections.singletonList(
+ new ManifestCommittable(identifier, watermark, compactMessages, properties));
+ }
+
+ private static List collectWrittenMessages(
+ List committables) {
+ List writtenMessages = new ArrayList<>();
+ for (ManifestCommittable committable : committables) {
+ writtenMessages.addAll(committable.fileCommittables());
+ }
+ return writtenMessages;
+ }
+
+ private static List compactMessagesFrom(
+ List rewrittenCommittables) {
+ return collectWrittenMessages(rewrittenCommittables);
+ }
+
+ private void maybeAbortAfterFailedCommit(
+ List writtenMessages,
+ List rewrittenCommittables,
+ long snapshotIdBeforeCommit,
+ Exception cause) {
+ List compactMessages = compactMessagesFrom(rewrittenCommittables);
+ if (rewriter.isBatchCompactCommitSucceeded(snapshotIdBeforeCommit, compactMessages)) {
+ return;
+ }
+ // Abort both the original written messages and the rewritten compact messages. The
+ // rewritten compact messages carry the new deletion-vector index files produced by
+ // dvMaintainer.persist() during rewrite, which are not referenced by the original written
+ // messages; aborting only the written messages would orphan them. For delete-only compact,
+ // writtenMessages is empty but compactMessages still carries the new DV index files, so it
+ // must be aborted (the previous writtenMessages.isEmpty() early return skipped cleanup
+ // entirely in that case).
+ abortWrittenQuietly(writtenMessages, cause);
+ abortCompactQuietly(compactMessages, cause);
+ }
+
+ private void abortWrittenQuietly(List writtenMessages, Exception cause) {
+ try {
+ rewriter.abortWrittenMessages(writtenMessages);
+ } catch (Exception abortException) {
+ cause.addSuppressed(abortException);
+ }
+ }
+
+ private void abortCompactQuietly(List compactMessages, Exception cause) {
+ try {
+ rewriter.abortCompactMessages(compactMessages);
+ } catch (Exception abortException) {
+ cause.addSuppressed(abortException);
+ }
+ }
+}
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactSinkBuilder.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactSinkBuilder.java
index c30ebc824b85..4beeeb006a7e 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactSinkBuilder.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SortCompactSinkBuilder.java
@@ -19,16 +19,52 @@
package org.apache.paimon.flink.sink;
import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.DataSplit;
+
+import javax.annotation.Nullable;
+
+import java.util.List;
/** A special version {@link FlinkSinkBuilder} for sort compact. */
public class SortCompactSinkBuilder extends FlinkSinkBuilder {
+ private long baseSnapshotId;
+ @Nullable private List compactInputSplits;
+ private boolean sortCompactInputSet = false;
+
public SortCompactSinkBuilder(Table table) {
super(table);
}
- public FlinkSinkBuilder forCompact(boolean compactSink) {
+ public SortCompactSinkBuilder forCompact(boolean compactSink) {
this.compactSink = compactSink;
return this;
}
+
+ /**
+ * Capture the base snapshot id and the planned compact input splits of the sort compact. The
+ * splits (serializable) are carried into the job graph and used at commit time to rewrite the
+ * written append files into a compact commit.
+ *
+ * Scale note: each {@link DataSplit} embeds full file metadata and is serialized into
+ * the committer factory closure. For tables with very large numbers of input files, this can
+ * significantly inflate the Flink job graph. Consider compacting in smaller partition batches
+ * when approaching hundreds of thousands of files.
+ */
+ public SortCompactSinkBuilder withSortCompactInput(
+ long baseSnapshotId, List compactInputSplits) {
+ this.baseSnapshotId = baseSnapshotId;
+ this.compactInputSplits = compactInputSplits;
+ this.sortCompactInputSet = true;
+ return this;
+ }
+
+ @Override
+ protected RowAppendTableSink createAppendTableSink() {
+ if (sortCompactInputSet) {
+ return new SortCompactAppendTableSink(
+ table, parallelism, baseSnapshotId, compactInputSplits);
+ }
+ return super.createAppendTableSink();
+ }
}
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/StoreCommitter.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/StoreCommitter.java
index 4c353517c3f0..e8786278aaf4 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/StoreCommitter.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/StoreCommitter.java
@@ -44,9 +44,9 @@
/** {@link Committer} for dynamic store. */
public class StoreCommitter implements Committer {
- private final TableCommitImpl commit;
+ protected final TableCommitImpl commit;
@Nullable private final CommitterMetrics committerMetrics;
- private final CommitListeners commitListeners;
+ protected final CommitListeners commitListeners;
@Nullable private final IOManager commitIOManager;
private final boolean allowLogOffsetDuplicate;
@@ -150,7 +150,7 @@ public boolean allowLogOffsetDuplicate() {
return allowLogOffsetDuplicate;
}
- private void calcNumBytesAndRecordsOut(List committables) {
+ protected void calcNumBytesAndRecordsOut(List committables) {
if (committerMetrics == null) {
return;
}
@@ -160,25 +160,32 @@ private void calcNumBytesAndRecordsOut(List committables) {
for (ManifestCommittable committable : committables) {
List commitMessages = committable.fileCommittables();
for (CommitMessage commitMessage : commitMessages) {
- long dataFileSizeInc =
- calcTotalFileSize(
- ((CommitMessageImpl) commitMessage).newFilesIncrement().newFiles());
- long dataFileRowCountInc =
- calcTotalFileRowCount(
- ((CommitMessageImpl) commitMessage).newFilesIncrement().newFiles());
- bytesOut += dataFileSizeInc;
- recordsOut += dataFileRowCountInc;
+ CommitMessageImpl impl = (CommitMessageImpl) commitMessage;
+ bytesOut += calcTotalFileSize(impl.newFilesIncrement().newFiles());
+ bytesOut += additionalBytesOut(impl);
+ recordsOut += calcTotalFileRowCount(impl.newFilesIncrement().newFiles());
+ recordsOut += additionalRecordsOut(impl);
}
}
committerMetrics.increaseNumBytesOut(bytesOut);
committerMetrics.increaseNumRecordsOut(recordsOut);
}
- private static long calcTotalFileSize(List files) {
+ /** Hook for subclasses to include extra output in committer metrics. */
+ protected long additionalBytesOut(CommitMessageImpl impl) {
+ return 0;
+ }
+
+ /** Hook for subclasses to include extra output in committer metrics. */
+ protected long additionalRecordsOut(CommitMessageImpl impl) {
+ return 0;
+ }
+
+ protected static long calcTotalFileSize(List files) {
return files.stream().mapToLong(DataFileMeta::fileSize).reduce(Long::sum).orElse(0);
}
- private static long calcTotalFileRowCount(List files) {
+ protected static long calcTotalFileRowCount(List files) {
return files.stream().mapToLong(DataFileMeta::rowCount).reduce(Long::sum).orElse(0);
}
}
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/SortCompactActionForAppendTableITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/SortCompactActionForAppendTableITCase.java
index d871c139107b..5302783415aa 100644
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/SortCompactActionForAppendTableITCase.java
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/SortCompactActionForAppendTableITCase.java
@@ -18,6 +18,9 @@
package org.apache.paimon.flink.action;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.append.SortCompactCommitMessageRewriter;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.Decimal;
@@ -30,6 +33,7 @@
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.Table;
import org.apache.paimon.table.sink.BatchTableCommit;
@@ -37,6 +41,7 @@
import org.apache.paimon.table.sink.BatchWriteBuilder;
import org.apache.paimon.table.sink.CommitMessage;
import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.shade.guava30.com.google.common.collect.Lists;
@@ -47,8 +52,12 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Random;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
/** Order Rewrite Action tests for {@link SortCompactAction}. */
@@ -330,6 +339,261 @@ public void testSortCompactionOnEmptyData() throws Exception {
.withOrderColumns(Collections.singletonList("f0"));
sortCompactAction.run();
+ // empty table: sort compact is a no-op and must not produce any snapshot (in particular
+ // no OVERWRITE snapshot)
+ Assertions.assertThat(getTable().snapshotManager().latestSnapshot()).isNull();
+ }
+
+ @Test
+ public void testSortCompactRejectsRowTrackingTable() throws Exception {
+ catalog.createDatabase(database, true);
+ Schema rowTrackingSchema =
+ Schema.newBuilder()
+ .column("f0", DataTypes.TINYINT())
+ .column("f1", DataTypes.INT())
+ .option("bucket", "-1")
+ .option("row-tracking.enabled", "true")
+ .build();
+ catalog.createTable(identifier(), rowTrackingSchema, true);
+
+ SortCompactAction sortCompactAction =
+ new SortCompactAction(
+ database,
+ tableName,
+ Collections.singletonMap("warehouse", warehouse),
+ Collections.emptyMap())
+ .withOrderStrategy("order")
+ .withOrderColumns(Collections.singletonList("f1"));
+
+ Assertions.assertThatThrownBy(sortCompactAction::run)
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Sort compact is unsupported for row tracking tables");
+ }
+
+ @Test
+ public void testSortCompactWithConflictingScanMode() throws Exception {
+ for (String scanMode : Arrays.asList("latest", "full", "from-timestamp", "incremental")) {
+ prepareData(300, 2);
+ long rowCountBeforeCompact = countRows();
+ Map tableConf =
+ Collections.singletonMap(CoreOptions.SCAN_MODE.key(), scanMode);
+ if ("from-timestamp".equals(scanMode)) {
+ tableConf =
+ Collections.singletonMap(
+ CoreOptions.SCAN_TIMESTAMP_MILLIS.key(), String.valueOf(0L));
+ } else if ("incremental".equals(scanMode)) {
+ tableConf = Collections.singletonMap(CoreOptions.INCREMENTAL_BETWEEN.key(), "1,2");
+ }
+ SortCompactAction sortCompactAction =
+ new SortCompactAction(
+ database,
+ tableName,
+ Collections.singletonMap("warehouse", warehouse),
+ tableConf)
+ .withOrderStrategy("order")
+ .withOrderColumns(Arrays.asList("f1", "f2"));
+ Assertions.assertThatCode(sortCompactAction::run).doesNotThrowAnyException();
+ Assertions.assertThat(getTable().snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+ Assertions.assertThat(countRows()).isEqualTo(rowCountBeforeCompact);
+ dropTable();
+ }
+ }
+
+ @Test
+ public void testSortCompactWithExplicitScanModeAndSnapshotIdConflict() throws Exception {
+ prepareData(300, 2);
+ long rowCountBeforeCompact = countRows();
+ Map tableConf = new HashMap<>();
+ tableConf.put(CoreOptions.SCAN_MODE.key(), "latest");
+ tableConf.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), "1");
+
+ SortCompactAction sortCompactAction =
+ new SortCompactAction(
+ database,
+ tableName,
+ Collections.singletonMap("warehouse", warehouse),
+ tableConf)
+ .withOrderStrategy("order")
+ .withOrderColumns(Arrays.asList("f1", "f2"));
+
+ Assertions.assertThatCode(sortCompactAction::run).doesNotThrowAnyException();
+ Assertions.assertThat(getTable().snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+ Assertions.assertThat(countRows()).isEqualTo(rowCountBeforeCompact);
+ }
+
+ @Test
+ public void testSortCompactWithHistoricalScanOptionsAfterSchemaEvolution() throws Exception {
+ createTable();
+ commit(writeSmallBatch(5));
+ getTable().schemaManager().commitChanges(SchemaChange.addColumn("f16", DataTypes.INT()));
+ int newColumnValue = 999_999;
+ commit(writeSmallBatchWithF16(5, newColumnValue));
+
+ FileStoreTable table = getTable();
+ long rowCountBeforeCompact = countRows(table);
+ long rowsWithNewColumnBeforeCompact = countRowsWithF16Value(table, newColumnValue);
+ Assertions.assertThat(rowsWithNewColumnBeforeCompact).isEqualTo(5);
+
+ SortCompactAction sortCompactAction =
+ new SortCompactAction(
+ database,
+ tableName,
+ Collections.singletonMap("warehouse", warehouse),
+ Collections.singletonMap(CoreOptions.SCAN_VERSION.key(), "1"))
+ .withOrderStrategy("order")
+ .withOrderColumns(Arrays.asList("f1", "f2"));
+
+ Assertions.assertThatCode(sortCompactAction::run).doesNotThrowAnyException();
+ Assertions.assertThat(getTable().snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+
+ table = getTable();
+ Assertions.assertThat(countRows(table)).isEqualTo(rowCountBeforeCompact);
+ Assertions.assertThat(countRowsWithF16Value(table, newColumnValue))
+ .isEqualTo(rowsWithNewColumnBeforeCompact);
+ }
+
+ @Test
+ public void testSortCompactProducesCompactCommit() throws Exception {
+ prepareData(300, 2);
+ order(Arrays.asList("f1", "f2"));
+
+ // sort compact must produce a COMPACT snapshot, not an OVERWRITE snapshot
+ Assertions.assertThat(getTable().snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+ }
+
+ @Test
+ public void testSortCompactDoesNotOverwriteConcurrentAppend() throws Exception {
+ // S0: prepare data and run sort compact, which produces a COMPACT snapshot
+ prepareData(300, 2);
+ order(Arrays.asList("f1", "f2"));
+ Snapshot compactSnapshot = getTable().snapshotManager().latestSnapshot();
+ Assertions.assertThat(compactSnapshot.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+ int filesAfterCompact = getTable().store().newScan().plan().files().size();
+
+ // S1: append more data after the sort compact
+ commit(writeData(100));
+
+ // the newly appended data must still be visible: a COMPACT commit only removes the
+ // compactBefore files and must not drop data appended after the base snapshot
+ Assertions.assertThat(getTable().snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.APPEND);
+ // new files have been added on top of the compacted files
+ Assertions.assertThat(getTable().store().newScan().plan().files().size())
+ .isGreaterThan(filesAfterCompact);
+ }
+
+ @Test
+ public void testSortCompactConcurrentAppendBeforeCommit() throws Exception {
+ // S0: capture the sort compact plan before any concurrent append
+ prepareData(300, 2);
+ FileStoreTable table = getTable();
+ SnapshotReader.Plan plan = table.newSnapshotReader().read();
+ long baseSnapshotId = plan.snapshotId();
+ List dataSplits = plan.dataSplits();
+ Set filesBeforeAppend = fileNames(table);
+
+ // Simulate the sort compact write stage (append-style commit messages, not yet committed)
+ List writtenMessages;
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite batchTableWrite = builder.newWrite()) {
+ for (int i = 0; i < 50; i++) {
+ batchTableWrite.write(data(0, i, i));
+ }
+ writtenMessages = batchTableWrite.prepareCommit();
+ }
+
+ // S1: append new data before the sort compact commit lands
+ commit(writeData(100));
+ Snapshot appendSnapshot = table.snapshotManager().latestSnapshot();
+ Assertions.assertThat(appendSnapshot.commitKind()).isEqualTo(Snapshot.CommitKind.APPEND);
+ Set appendOnlyFiles = new HashSet<>(fileNames(table));
+ appendOnlyFiles.removeAll(filesBeforeAppend);
+ Assertions.assertThat(appendOnlyFiles).isNotEmpty();
+
+ // Commit the sort compact from the S0 plan
+ SortCompactCommitMessageRewriter rewriter =
+ new SortCompactCommitMessageRewriter(table, baseSnapshotId, dataSplits);
+ List compactMessages = rewriter.rewrite(writtenMessages);
+ BatchTableCommit batchCommit = table.newBatchWriteBuilder().newCommit();
+ batchCommit.commit(compactMessages);
+ batchCommit.close();
+
+ Snapshot compactSnapshot = table.snapshotManager().latestSnapshot();
+ Assertions.assertThat(compactSnapshot.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+ // S1 data appended before compact commit must remain visible
+ Assertions.assertThat(fileNames(table)).containsAll(appendOnlyFiles);
+ }
+
+ private static Set fileNames(FileStoreTable table) {
+ Set names = new HashSet<>();
+ for (ManifestEntry entry : table.store().newScan().plan().files()) {
+ names.add(entry.file().fileName());
+ }
+ return names;
+ }
+
+ private long countRows() throws Exception {
+ return countRows(getTable());
+ }
+
+ private long countRows(FileStoreTable table) throws Exception {
+ return getResult(
+ table.newRead(), table.newSnapshotReader().read().splits(), table.rowType())
+ .size();
+ }
+
+ private long countRowsWithF16Value(FileStoreTable table, int expected) throws Exception {
+ String expectedSuffix = ", " + expected + "]";
+ return getResult(
+ table.newRead(), table.newSnapshotReader().read().splits(), table.rowType())
+ .stream()
+ .filter(row -> row.endsWith(expectedSuffix))
+ .count();
+ }
+
+ private List writeSmallBatch(int size) throws Exception {
+ BatchWriteBuilder builder = getTable().newBatchWriteBuilder();
+ try (BatchTableWrite batchTableWrite = builder.newWrite()) {
+ for (int i = 0; i < size; i++) {
+ batchTableWrite.write(data(0, i, i));
+ }
+ return batchTableWrite.prepareCommit();
+ }
+ }
+
+ private List writeSmallBatchWithF16(int size, int f16) throws Exception {
+ BatchWriteBuilder builder = getTable().newBatchWriteBuilder();
+ try (BatchTableWrite batchTableWrite = builder.newWrite()) {
+ for (int i = 0; i < size; i++) {
+ batchTableWrite.write(dataWithF16(0, i, i, f16));
+ }
+ return batchTableWrite.prepareCommit();
+ }
+ }
+
+ private static InternalRow dataWithF16(int p, int i, int j, int f16) {
+ return GenericRow.of(
+ (byte) p,
+ j,
+ (short) i,
+ BinaryString.fromString(String.valueOf(j)),
+ 0.1 + i,
+ BinaryString.fromString(String.valueOf(j)),
+ BinaryString.fromString(String.valueOf(i)),
+ j % 2 == 1,
+ i,
+ j,
+ Timestamp.fromEpochMillis(i),
+ Decimal.zero(10, 2),
+ String.valueOf(i).getBytes(),
+ (float) 0.1 + j,
+ randomBytes(),
+ randomBytes(),
+ f16);
}
private void zorder(List columns) throws Exception {
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/SortCompactActionForDynamicBucketITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/SortCompactActionForDynamicBucketITCase.java
deleted file mode 100644
index 11407c587a0c..000000000000
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/SortCompactActionForDynamicBucketITCase.java
+++ /dev/null
@@ -1,268 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.paimon.flink.action;
-
-import org.apache.paimon.CoreOptions;
-import org.apache.paimon.catalog.Identifier;
-import org.apache.paimon.data.BinaryString;
-import org.apache.paimon.data.GenericRow;
-import org.apache.paimon.data.InternalRow;
-import org.apache.paimon.manifest.ManifestEntry;
-import org.apache.paimon.operation.KeyValueFileStoreScan;
-import org.apache.paimon.predicate.Predicate;
-import org.apache.paimon.predicate.PredicateBuilder;
-import org.apache.paimon.schema.Schema;
-import org.apache.paimon.table.FileStoreTable;
-import org.apache.paimon.table.Table;
-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.types.DataTypes;
-import org.apache.paimon.utils.Pair;
-
-import org.assertj.core.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Random;
-
-/** Sort Compact Action tests for dynamic bucket table. */
-public class SortCompactActionForDynamicBucketITCase extends ActionITCaseBase {
-
- private static final Random RANDOM = new Random();
-
- @Test
- public void testDynamicBucketSort() throws Exception {
- createTable();
-
- commit(writeData(100));
- PredicateBuilder predicateBuilder = new PredicateBuilder(getTable().rowType());
- Predicate predicate = predicateBuilder.between(1, 100L, 200L);
-
- List files = getTable().store().newScan().plan().files();
- List filesFilter =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
-
- zorder(Arrays.asList("f2", "f1"));
-
- List filesZorder = getTable().store().newScan().plan().files();
- List filesFilterZorder =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
- Assertions.assertThat(filesFilterZorder.size() / (double) filesZorder.size())
- .isLessThan(filesFilter.size() / (double) files.size());
- }
-
- @Test
- public void testDynamicBucketSortWithOrderAndZorder() throws Exception {
- createTable();
-
- commit(writeData(100));
- PredicateBuilder predicateBuilder = new PredicateBuilder(getTable().rowType());
- Predicate predicate = predicateBuilder.between(1, 100L, 200L);
-
- // order f2,f1 will make predicate of f1 perform worse.
- order(Arrays.asList("f2", "f1"));
- List files = getTable().store().newScan().plan().files();
- List filesFilter =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
-
- zorder(Arrays.asList("f2", "f1"));
-
- List filesZorder = getTable().store().newScan().plan().files();
- List filesFilterZorder =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
-
- Assertions.assertThat(filesFilterZorder.size() / (double) filesZorder.size())
- .isLessThan(filesFilter.size() / (double) files.size());
- }
-
- @Test
- public void testDynamicBucketSortWithOrderAndHilbert() throws Exception {
- createTable();
-
- commit(writeData(100));
- PredicateBuilder predicateBuilder = new PredicateBuilder(getTable().rowType());
- Predicate predicate = predicateBuilder.between(1, 100L, 200L);
-
- // order f2,f1 will make predicate of f1 perform worse.
- order(Arrays.asList("f2", "f1"));
- List files = getTable().store().newScan().plan().files();
- List filesFilter =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
-
- hilbert(Arrays.asList("f2", "f1"));
-
- List filesHilbert = getTable().store().newScan().plan().files();
- List filesFilterHilbert =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
-
- Assertions.assertThat(filesFilterHilbert.size() / (double) filesHilbert.size())
- .isLessThan(filesFilter.size() / (double) files.size());
- }
-
- @Test
- public void testDynamicBucketSortWithStringType() throws Exception {
- createTable();
-
- commit(writeData(100));
- PredicateBuilder predicateBuilder = new PredicateBuilder(getTable().rowType());
- Predicate predicate =
- predicateBuilder.between(
- 4,
- BinaryString.fromString("000000000" + 100),
- BinaryString.fromString("000000000" + 200));
-
- List files = getTable().store().newScan().plan().files();
- List filesFilter =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
-
- zorder(Collections.singletonList("f4"));
-
- List filesZorder = getTable().store().newScan().plan().files();
- List filesFilterZorder =
- ((KeyValueFileStoreScan) getTable().store().newScan())
- .withValueFilter(predicate)
- .plan()
- .files();
- Assertions.assertThat(filesFilterZorder.size() / (double) filesZorder.size())
- .isLessThan(filesFilter.size() / (double) files.size());
- }
-
- private void zorder(List columns) throws Exception {
- createAction("zorder", columns).run();
- }
-
- private void hilbert(List columns) throws Exception {
- createAction("hilbert", columns).run();
- }
-
- private void order(List columns) throws Exception {
- createAction("order", columns).run();
- }
-
- private SortCompactAction createAction(String orderStrategy, List columns) {
- return createAction(
- SortCompactAction.class,
- "compact",
- "--warehouse",
- warehouse,
- "--database",
- database,
- "--table",
- tableName,
- "--order_strategy",
- orderStrategy,
- "--order_by",
- String.join(",", columns));
- }
-
- // schema with all the basic types.
- private static Schema schema() {
- Schema.Builder schemaBuilder = Schema.newBuilder();
- schemaBuilder.column("f0", DataTypes.BIGINT());
- schemaBuilder.column("f1", DataTypes.BIGINT());
- schemaBuilder.column("f2", DataTypes.BIGINT());
- schemaBuilder.column("f3", DataTypes.BIGINT());
- schemaBuilder.column("f4", DataTypes.STRING());
- schemaBuilder.option("bucket", "-1");
- schemaBuilder.option("scan.parallelism", "6");
- schemaBuilder.option("sink.parallelism", "3");
- schemaBuilder.option("dynamic-bucket.target-row-num", "100");
- schemaBuilder.option(CoreOptions.ZORDER_VAR_LENGTH_CONTRIBUTION.key(), "14");
- schemaBuilder.primaryKey("f0");
- return schemaBuilder.build();
- }
-
- private List writeData(int size) throws Exception {
- List messages;
- Table table = getTable();
- BatchWriteBuilder builder = table.newBatchWriteBuilder();
- try (BatchTableWrite batchTableWrite = builder.newWrite()) {
- for (int i = 0; i < size; i++) {
- for (int j = 0; j < 100; j++) {
- Pair rowWithBucket = data(i);
- batchTableWrite.write(rowWithBucket.getKey(), rowWithBucket.getValue());
- }
- }
- messages = batchTableWrite.prepareCommit();
- }
-
- return messages;
- }
-
- private void commit(List messages) throws Exception {
- BatchTableCommit commit = getTable().newBatchWriteBuilder().newCommit();
- commit.commit(messages);
- commit.close();
- }
-
- private void createTable() throws Exception {
- catalog.createDatabase(database, true);
- catalog.createTable(identifier(), schema(), true);
- }
-
- private FileStoreTable getTable() throws Exception {
- return (FileStoreTable) catalog.getTable(identifier());
- }
-
- private Identifier identifier() {
- return Identifier.create(database, tableName);
- }
-
- private static Pair data(int bucket) {
- String in = String.valueOf(Math.abs(RANDOM.nextInt(10000)));
- int count = 4 - in.length();
- for (int i = 0; i < count; i++) {
- in = "0" + in;
- }
- assert in.length() == 4;
- GenericRow row =
- GenericRow.of(
- RANDOM.nextLong(),
- (long) RANDOM.nextInt(10000),
- (long) RANDOM.nextInt(10000),
- (long) RANDOM.nextInt(10000),
- BinaryString.fromString("00000000" + in));
- return Pair.of(row, bucket);
- }
-}
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactProcedureITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactProcedureITCase.java
index afe45cef66f5..4ef33caf883e 100644
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactProcedureITCase.java
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactProcedureITCase.java
@@ -272,24 +272,20 @@ public void testForceCompactToExternalPath() throws Exception {
// ----------------------- Sort Compact -----------------------
@Test
- public void testDynamicBucketSortCompact() throws Exception {
+ public void testSortCompactForAppendTable() throws Exception {
ThreadLocalRandom random = ThreadLocalRandom.current();
sql(
"CREATE TABLE T ("
- + " f0 BIGINT PRIMARY KEY NOT ENFORCED,"
+ + " f0 BIGINT,"
+ " f1 BIGINT,"
+ " f2 BIGINT,"
+ " f3 BIGINT,"
+ " f4 STRING"
+ ") WITH ("
+ " 'write-only' = 'true',"
- + " 'dynamic-bucket.target-row-num' = '100',"
+ + " 'bucket' = '-1',"
+ " 'zorder.var-length-contribution' = '14'"
+ ")");
- boolean overwriteUpgrade = random.nextBoolean();
- if (!overwriteUpgrade) {
- sql("ALTER TABLE T SET ('overwrite-upgrade' = 'false')");
- }
FileStoreTable table = paimonTable("T");
int commitTimes = 20;
@@ -315,7 +311,17 @@ public void testDynamicBucketSortCompact() throws Exception {
sql(
"CALL sys.compact(`table` => 'default.T', order_strategy => 'zorder', order_by => 'f2,f1')");
- checkLatestSnapshot(table, 21, Snapshot.CommitKind.OVERWRITE);
+ checkLatestSnapshot(table, 21, Snapshot.CommitKind.COMPACT);
+ }
+
+ @Test
+ public void testEmptySortCompactProcedure() throws Exception {
+ sql("CREATE TABLE T (f0 INT, f1 INT) WITH ('bucket' = '-1')");
+ FileStoreTable table = paimonTable("T");
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ sql(
+ "CALL sys.compact(`table` => 'default.T', order_strategy => 'zorder', order_by => 'f0')");
+ Assertions.assertThat(table.snapshotManager().latestSnapshot()).isNull();
}
// ----------------------- Minor Compact -----------------------
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterOperatorTest.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterOperatorTest.java
index c533e175fa69..24d5bb280055 100644
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterOperatorTest.java
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterOperatorTest.java
@@ -640,6 +640,47 @@ public void testCalcDataBytesSend() throws Exception {
committer.close();
}
+ @Test
+ public void testCalcDataBytesSendFromCompactAfter() throws Exception {
+ FileStoreTable table = createFileStoreTable();
+
+ StreamTableWrite write = table.newWrite(initialCommitUser);
+ write.write(GenericRow.of(1, 10L));
+ write.write(GenericRow.of(1, 20L));
+ List committable = write.prepareCommit(false, 0);
+ write.close();
+
+ ManifestCommittable manifestCommittable = new ManifestCommittable(0);
+ for (CommitMessage commitMessage : committable) {
+ CommitMessageImpl impl = (CommitMessageImpl) commitMessage;
+ manifestCommittable.addFileCommittable(
+ new CommitMessageImpl(
+ impl.partition(),
+ impl.bucket(),
+ impl.totalBuckets(),
+ DataIncrement.emptyIncrement(),
+ new CompactIncrement(
+ Collections.emptyList(),
+ impl.newFilesIncrement().newFiles(),
+ Collections.emptyList(),
+ impl.newFilesIncrement().newIndexFiles(),
+ impl.newFilesIncrement().deletedIndexFiles())));
+ }
+
+ StreamTableCommit commit = table.newCommit(initialCommitUser);
+ OperatorMetricGroup metricGroup = UnregisteredMetricsGroup.createOperatorMetricGroup();
+ StoreCommitter committer =
+ new StoreCommitter(
+ table,
+ commit,
+ Committer.createContext("", metricGroup, true, false, null, 1, 1));
+ committer.commit(Collections.singletonList(manifestCommittable));
+ CommitterMetrics metrics = committer.getCommitterMetrics();
+ assertThat(metrics.getNumBytesOutCounter().getCount()).isEqualTo(0);
+ assertThat(metrics.getNumRecordsOutCounter().getCount()).isEqualTo(0);
+ committer.close();
+ }
+
@Test
public void testCalcDataBytesSendViaFilterAndCommit() throws Exception {
FileStoreTable table = createFileStoreTable();
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/SortCompactCommitterTest.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/SortCompactCommitterTest.java
new file mode 100644
index 000000000000..a3fcae94497b
--- /dev/null
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/SortCompactCommitterTest.java
@@ -0,0 +1,820 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.TestAppendFileStore;
+import org.apache.paimon.TestKeyValueGenerator;
+import org.apache.paimon.append.SortCompactCommitMessageRewriter;
+import org.apache.paimon.append.SortCompactPlanMetadata;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.flink.FlinkConnectorOptions;
+import org.apache.paimon.flink.sink.listener.CommitListener;
+import org.apache.paimon.flink.sink.listener.CommitListenerFactory;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileIOFinder;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.SchemaUtils;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.sink.TableCommitImpl;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.TraceableFileIO;
+
+import org.apache.flink.metrics.groups.OperatorMetricGroup;
+import org.apache.flink.metrics.groups.UnregisteredMetricsGroup;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.apache.paimon.io.DataFileTestUtils.newFile;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.spy;
+
+/** Test for {@link SortCompactCommitter}. */
+public class SortCompactCommitterTest {
+
+ @TempDir java.nio.file.Path tempDir;
+
+ @Test
+ public void testMergePartialCommittablesBeforeRewrite() throws Exception {
+ TestAppendFileStore store = createAppendStore(new HashMap<>());
+ CommitMessageImpl initial =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc"));
+ store.commit(initial);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ SnapshotReader.Plan plan = table.newSnapshotReader().read();
+ Long baseSnapshotId = plan.snapshotId();
+ List dataSplits = plan.dataSplits();
+ long snapshotsBeforeCommit = table.snapshotManager().snapshotCount();
+
+ DataFileMeta sorted0 = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted1 = newFile("sorted-1.orc", 0, 101, 200, 200);
+ CommitMessageImpl written0 =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted0),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+ CommitMessageImpl written1 =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted1),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ ManifestCommittable first = new ManifestCommittable(1L, 10L);
+ first.addFileCommittable(written0);
+ ManifestCommittable second = new ManifestCommittable(2L, 20L);
+ second.addFileCommittable(written1);
+
+ String commitUser = UUID.randomUUID().toString();
+ try (TableCommitImpl commit = table.newCommit(commitUser)) {
+ SortCompactCommitter committer =
+ new SortCompactCommitter(
+ table,
+ commit,
+ Committer.createContext(commitUser, null, true, false, null, 1, 1),
+ new SortCompactCommitMessageRewriter(
+ table,
+ baseSnapshotId == null ? 0L : baseSnapshotId,
+ dataSplits));
+ committer.commit(Arrays.asList(first, second));
+ }
+
+ assertThat(table.snapshotManager().snapshotCount()).isEqualTo(snapshotsBeforeCommit + 1);
+ Snapshot latest = table.snapshotManager().latestSnapshot();
+ assertThat(latest.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+
+ List remainingFiles = new ArrayList<>();
+ for (ManifestEntry entry : table.store().newScan().plan().files()) {
+ remainingFiles.add(entry.file());
+ }
+ assertThat(remainingFiles)
+ .containsExactlyInAnyOrder(
+ sorted0.assignFileSource(FileSource.COMPACT),
+ sorted1.assignFileSource(FileSource.COMPACT));
+ }
+
+ @Test
+ public void testCountsCompactAfterInMetrics() throws Exception {
+ TestAppendFileStore store = createAppendStore(new HashMap<>());
+ CommitMessageImpl initial =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc"));
+ store.commit(initial);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ SnapshotReader.Plan plan = table.newSnapshotReader().read();
+ Long baseSnapshotId = plan.snapshotId();
+ List dataSplits = plan.dataSplits();
+
+ DataFileMeta sorted0 = newFile("sorted-0.orc", 0, 0, 100, 100);
+ DataFileMeta sorted1 = newFile("sorted-1.orc", 0, 101, 200, 200);
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Arrays.asList(sorted0, sorted1),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+
+ ManifestCommittable manifestCommittable = new ManifestCommittable(1L, 10L);
+ manifestCommittable.addFileCommittable(written);
+
+ String commitUser = UUID.randomUUID().toString();
+ OperatorMetricGroup metricGroup = UnregisteredMetricsGroup.createOperatorMetricGroup();
+ try (TableCommitImpl commit = table.newCommit(commitUser)) {
+ SortCompactCommitter committer =
+ new SortCompactCommitter(
+ table,
+ commit,
+ Committer.createContext(
+ commitUser, metricGroup, true, false, null, 1, 1),
+ new SortCompactCommitMessageRewriter(
+ table,
+ baseSnapshotId == null ? 0L : baseSnapshotId,
+ dataSplits));
+ committer.commit(Collections.singletonList(manifestCommittable));
+ assertThat(committer.getCommitterMetrics().getNumBytesOutCounter().getCount())
+ .isGreaterThan(0);
+ assertThat(committer.getCommitterMetrics().getNumRecordsOutCounter().getCount())
+ .isGreaterThan(0);
+ }
+ }
+
+ @Test
+ public void testCommitUsesCapturedPlanMetadataWhenBaseSnapshotExpired() throws Exception {
+ Map options = new HashMap<>();
+ options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+ TestAppendFileStore store = createAppendStore(options);
+ CommitMessageImpl initial =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc"));
+ store.commit(initial);
+
+ Map> dvs = new HashMap<>();
+ dvs.put("data-0.orc", Arrays.asList(1, 3, 5));
+ CommitMessageImpl dvMessage = store.writeDVIndexFiles(BinaryRow.EMPTY_ROW, 0, dvs);
+ store.commit(dvMessage);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ SnapshotReader.Plan plan = table.newSnapshotReader().read();
+ long baseSnapshotId = plan.snapshotId();
+ List dataSplits = plan.dataSplits();
+ SortCompactPlanMetadata planMetadata =
+ SortCompactPlanMetadata.capture(table, baseSnapshotId, dataSplits);
+
+ CommitMessageImpl keepLatestSnapshotAlive =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Collections.singletonList("data-2.orc"));
+ store.commit(keepLatestSnapshotAlive);
+
+ DataFileMeta sorted = newFile("sorted-0.orc", 0, 0, 100, 100);
+ CommitMessageImpl written =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ table.coreOptions().bucket(),
+ new DataIncrement(
+ Collections.singletonList(sorted),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+ ManifestCommittable manifestCommittable = new ManifestCommittable(1L, 10L);
+ manifestCommittable.addFileCommittable(written);
+
+ table.snapshotManager().deleteSnapshot(baseSnapshotId);
+
+ List rewritten =
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, dataSplits, planMetadata)
+ .rewrite(Collections.singletonList(written));
+ assertThat(((CommitMessageImpl) rewritten.get(0)).compactIncrement().deletedIndexFiles())
+ .isNotEmpty();
+
+ String commitUser = UUID.randomUUID().toString();
+ try (TableCommitImpl commit = table.newCommit(commitUser)) {
+ SortCompactCommitter committer =
+ new SortCompactCommitter(
+ table,
+ commit,
+ Committer.createContext(commitUser, null, true, false, null, 1, 1),
+ new SortCompactCommitMessageRewriter(
+ table, baseSnapshotId, dataSplits, planMetadata));
+ committer.commit(Collections.singletonList(manifestCommittable));
+ }
+
+ Snapshot latest = table.snapshotManager().latestSnapshot();
+ assertThat(latest.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+ assertThat(fileNames(table)).contains("sorted-0.orc");
+ }
+
+ private static Set fileNames(FileStoreTable table) {
+ Set names = new HashSet<>();
+ for (ManifestEntry entry : table.store().newScan().plan().files()) {
+ names.add(entry.file().fileName());
+ }
+ return names;
+ }
+
+ @Test
+ public void testCommitDeleteOnlyWhenWriterProducesNoCommittable() throws Exception {
+ TestAppendFileStore store = createAppendStore(new HashMap<>());
+ CommitMessageImpl initial =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc"));
+ store.commit(initial);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ SnapshotReader.Plan plan = table.newSnapshotReader().read();
+ long snapshotsBeforeCommit = table.snapshotManager().snapshotCount();
+
+ String commitUser = UUID.randomUUID().toString();
+ try (TableCommitImpl commit = table.newCommit(commitUser)) {
+ SortCompactCommitter committer =
+ new SortCompactCommitter(
+ table,
+ commit,
+ Committer.createContext(commitUser, null, true, false, null, 1, 1),
+ new SortCompactCommitMessageRewriter(
+ table,
+ plan.snapshotId() == null ? 0L : plan.snapshotId(),
+ plan.dataSplits()));
+ committer.commit(Collections.emptyList());
+ }
+
+ assertThat(table.snapshotManager().snapshotCount()).isEqualTo(snapshotsBeforeCommit + 1);
+ assertThat(table.snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+ assertThat(table.store().newScan().plan().files()).isEmpty();
+ }
+
+ @Test
+ public void testFilterAndCommitRecoveryDoesNotDeletePlannedInput() throws Exception {
+ TestAppendFileStore store = createAppendStore(new HashMap<>());
+ CommitMessageImpl initial =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc"));
+ store.commit(initial);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ SnapshotReader.Plan plan = table.newSnapshotReader().read();
+ long snapshotsBeforeRecover = table.snapshotManager().snapshotCount();
+
+ String commitUser = UUID.randomUUID().toString();
+ try (TableCommitImpl commit = table.newCommit(commitUser)) {
+ SortCompactCommitter committer =
+ new SortCompactCommitter(
+ table,
+ commit,
+ Committer.createContext(commitUser, null, true, false, null, 1, 1),
+ new SortCompactCommitMessageRewriter(
+ table,
+ plan.snapshotId() == null ? 0L : plan.snapshotId(),
+ plan.dataSplits()));
+ int committed = committer.filterAndCommit(Collections.emptyList(), true, true);
+ assertThat(committed).isZero();
+ }
+
+ assertThat(table.snapshotManager().snapshotCount()).isEqualTo(snapshotsBeforeRecover);
+ assertThat(table.store().newScan().plan().files()).hasSize(2);
+ }
+
+ @Test
+ public void testFilterAndCommitDeleteOnlyWhenWriterProducesNoCommittable() throws Exception {
+ TestAppendFileStore store = createAppendStore(new HashMap<>());
+ CommitMessageImpl initial =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc"));
+ store.commit(initial);
+
+ FileStoreTable table =
+ FileStoreTableFactory.create(
+ store.fileIO(), store.options().path(), store.schema());
+ SnapshotReader.Plan plan = table.newSnapshotReader().read();
+ long snapshotsBeforeCommit = table.snapshotManager().snapshotCount();
+
+ String commitUser = UUID.randomUUID().toString();
+ try (TableCommitImpl commit = table.newCommit(commitUser)) {
+ SortCompactCommitter committer =
+ new SortCompactCommitter(
+ table,
+ commit,
+ Committer.createContext(commitUser, null, true, false, null, 1, 1),
+ new SortCompactCommitMessageRewriter(
+ table,
+ plan.snapshotId() == null ? 0L : plan.snapshotId(),
+ plan.dataSplits()));
+ int committed = committer.filterAndCommit(Collections.emptyList(), false, true);
+ assertThat(committed).isEqualTo(1);
+ assertThat(committer.filterAndCommit(Collections.emptyList(), false, true)).isZero();
+ }
+
+ assertThat(table.snapshotManager().snapshotCount()).isEqualTo(snapshotsBeforeCommit + 1);
+ assertThat(table.snapshotManager().latestSnapshot().commitKind())
+ .isEqualTo(Snapshot.CommitKind.COMPACT);
+ assertThat(table.store().newScan().plan().files()).isEmpty();
+ }
+
+ @Test
+ public void testFilterAndCommitRecoveryDoesNotCreateDvIndexFiles() throws Exception {
+ Map options = new HashMap<>();
+ options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+ TestAppendFileStore store = createAppendStore(options);
+ CommitMessageImpl initial =
+ store.writeDataFiles(
+ BinaryRow.EMPTY_ROW, 0, Arrays.asList("data-0.orc", "data-1.orc"));
+ store.commit(initial);
+
+ Map