This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 157e02bd43 [core] Validate chain partition drops against the
post-commit state (#10127)
157e02bd43 is described below
commit 157e02bd432fbd332fdbbf0b8cfccd2897ca5675
Author: YangJie <[email protected]>
AuthorDate: Wed Sep 23 23:00:19 2026 -0400
[core] Validate chain partition drops against the post-commit state (#10127)
---
.../metastore/ChainTableCommitPreCallback.java | 63 +++++++++++++++--
.../ChainTableOverwriteCommitCallback.java | 15 ++++-
.../paimon/metastore/ChainTableOverwriteScope.java | 75 +++++++++++++++++++++
.../operation/ChainTablePartitionExpireTest.java | 78 ++++++++++++++++++++++
4 files changed, 225 insertions(+), 6 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableCommitPreCallback.java
b/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableCommitPreCallback.java
index 03399a4fde..08f02b2e95 100644
---
a/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableCommitPreCallback.java
+++
b/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableCommitPreCallback.java
@@ -29,7 +29,6 @@ import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.manifest.PartitionEntry;
import org.apache.paimon.manifest.SimpleFileEntry;
-import org.apache.paimon.operation.commit.ManifestEntryChanges;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.table.FileStoreTable;
@@ -45,8 +44,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
+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.stream.Collectors;
/**
@@ -55,7 +58,8 @@ import java.util.stream.Collectors;
* <p>This callback performs a pre-check before dropping partitions on the
snapshot branch of a
* chain table. It verifies that a snapshot partition being dropped is either
followed by no delta
* partitions in the chain interval or has a previous snapshot partition that
can serve as its
- * predecessor.
+ * predecessor. The check considers the post-commit state, so partitions
dropped by the same commit
+ * do not count as predecessors or successors.
*
* <p>The callback is only executed when all of following conditions are met:
*
@@ -99,8 +103,6 @@ public class ChainTableCommitPreCallback implements
CommitPreCallback {
if (!isPureDeleteCommit(deltaFiles, indexFiles)) {
return;
}
- List<BinaryRow> changedPartitions =
- ManifestEntryChanges.changedPartitions(deltaFiles, indexFiles);
FileStoreTable candidateTable =
ChainTableUtils.resolveChainPrimaryTable(table);
FileStoreTable deltaTable =
candidateTable.switchToBranch(coreOptions.scanFallbackDeltaBranch());
@@ -123,13 +125,30 @@ public class ChainTableCommitPreCallback implements
CommitPreCallback {
RecordComparator chainComparator =
CodeGenUtils.newRecordComparator(projector.chainPartitionType().getFieldTypes());
+ // The pure-delete commit may drop several partitions of a group at
once (batch
+ // overwrite / rollback). Validation must consider the post-commit
state: a partition
+ // dropped by this very commit can no longer serve as predecessor or
successor, or a
+ // delta partition would silently lose its baseline rows once the
commit lands. A
+ // partition counts as dropped only if the commit deletes ALL of its
base files; a
+ // rollback deletes per-file and may leave a partition partially
alive, and such a
+ // partition still serves as a baseline after the commit.
+ Set<BinaryRow> droppedPartitions = fullyDroppedPartitions(baseFiles,
deltaFiles);
List<BinaryRow> snapshotPartitions =
table.newSnapshotReader().partitionEntries().stream()
.map(PartitionEntry::partition)
+ .filter(partition ->
!droppedPartitions.contains(partition))
.collect(Collectors.toList());
SnapshotReader deltaSnapshotReader = deltaTable.newSnapshotReader();
PredicateBuilder builder = new PredicateBuilder(partitionType);
- for (BinaryRow partition : changedPartitions) {
+ // Delta partitions that the triggering chain-table OVERWRITE just
rewrote hold fresh,
+ // complete data and do not depend on a snapshot baseline, so dropping
their baseline is
+ // intended rather than an orphan. A standalone drop or a rollback
leaves this empty, so a
+ // genuinely stranded follower is still rejected below.
+ Set<BinaryRow> freshlyWrittenDeltaPartitions =
+ ChainTableOverwriteScope.freshlyWrittenDeltaPartitions();
+ // only fully dropped partitions can break the chain; a partially
deleted partition
+ // survives the commit and keeps anchoring its delta followers
+ for (BinaryRow partition : droppedPartitions) {
BinaryRow partitionGroup =
projector.extractGroupPartition(partition);
BinaryRow partitionChain =
projector.extractChainPartition(partition);
@@ -166,6 +185,9 @@ public class ChainTableCommitPreCallback implements
CommitPreCallback {
nextSnapshotPartition,
chainComparator,
projector))
+ .filter(
+ deltaPartition ->
+
!freshlyWrittenDeltaPartitions.contains(deltaPartition))
.collect(Collectors.toList());
boolean canDrop =
deltaFollowingPartitions.isEmpty() ||
preSnapshotPartition.isPresent();
@@ -181,6 +203,37 @@ public class ChainTableCommitPreCallback implements
CommitPreCallback {
}
}
+ private Set<BinaryRow> fullyDroppedPartitions(
+ List<SimpleFileEntry> baseFiles, List<ManifestEntry> deltaFiles) {
+ Map<BinaryRow, Set<String>> deletedFilesByPartition = new HashMap<>();
+ for (ManifestEntry entry : deltaFiles) {
+ if (entry.kind() == FileKind.DELETE) {
+ deletedFilesByPartition
+ .computeIfAbsent(entry.partition(), k -> new
HashSet<>())
+ .add(entry.bucket() + "/" + entry.file().fileName());
+ }
+ }
+ Set<BinaryRow> droppedPartitions = new HashSet<>();
+ for (Map.Entry<BinaryRow, Set<String>> deleted :
deletedFilesByPartition.entrySet()) {
+ BinaryRow partition = deleted.getKey();
+ Set<String> deletedFiles = deleted.getValue();
+ List<String> partitionBaseFiles =
+ baseFiles.stream()
+ .filter(base -> base.partition().equals(partition))
+ .map(base -> base.bucket() + "/" + base.fileName())
+ .collect(Collectors.toList());
+ // A partition with no base files was never a baseline, so it
cannot break the
+ // chain; treat it as dropped only when the commit deletes every
one of its base
+ // files. Guarding the empty case keeps a caller that passes an
incomplete base
+ // file list (e.g. a path that scans only changed partitions) from
misclassifying
+ // a partially deleted survivor as fully dropped.
+ if (!partitionBaseFiles.isEmpty() &&
deletedFiles.containsAll(partitionBaseFiles)) {
+ droppedPartitions.add(partition);
+ }
+ }
+ return droppedPartitions;
+ }
+
private boolean isPureDeleteCommit(
List<ManifestEntry> deltaFiles, List<IndexManifestEntry>
indexFiles) {
return deltaFiles.stream().allMatch(f -> f.kind() == FileKind.DELETE)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableOverwriteCommitCallback.java
b/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableOverwriteCommitCallback.java
index 2d1a0cd705..67255bd7f4 100644
---
a/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableOverwriteCommitCallback.java
+++
b/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableOverwriteCommitCallback.java
@@ -29,8 +29,10 @@ import org.apache.paimon.table.sink.CommitCallback;
import org.apache.paimon.utils.ChainTableUtils;
import org.apache.paimon.utils.InternalRowPartitionComputer;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -97,7 +99,18 @@ public class ChainTableOverwriteCommitCallback implements
CommitCallback {
.collect(Collectors.toList());
try (BatchTableCommit commit =
snapshotTable.newBatchWriteBuilder().newCommit()) {
- commit.truncatePartitions(candidatePartitions);
+ // The truncated snapshot partitions are exactly the partitions
this overwrite just
+ // rewrote on the delta branch, so their surviving delta followers
hold fresh data
+ // and do not depend on a snapshot baseline. Hand that set to the
pre-callback that
+ // the truncate triggers so it does not reject dropping their
baselines.
+ Set<BinaryRow> freshlyWritten = new HashSet<>(overwritePartitions);
+ Set<BinaryRow> previous =
+
ChainTableOverwriteScope.setFreshlyWrittenDeltaPartitions(freshlyWritten);
+ try {
+ commit.truncatePartitions(candidatePartitions);
+ } finally {
+ ChainTableOverwriteScope.restore(previous);
+ }
} catch (Exception e) {
throw new RuntimeException(
String.format(
diff --git
a/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableOverwriteScope.java
b/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableOverwriteScope.java
new file mode 100644
index 0000000000..3648102abe
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/metastore/ChainTableOverwriteScope.java
@@ -0,0 +1,75 @@
+/*
+ * 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.metastore;
+
+import org.apache.paimon.data.BinaryRow;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Carries the set of delta partitions that a chain-table OVERWRITE freshly
(re)wrote from {@link
+ * ChainTableOverwriteCommitCallback} to the snapshot-branch truncate's {@link
+ * ChainTableCommitPreCallback}.
+ *
+ * <p>The overwrite callback truncates the snapshot branch synchronously, on
the same thread, and
+ * that truncate is what invokes the pre-callback. A thread-local scoped
around the truncate call
+ * therefore reaches the pre-callback without changing the generic commit path
or the {@link
+ * org.apache.paimon.table.sink.CommitPreCallback} signature.
+ *
+ * <p>Why the pre-callback needs it: a delta partition that this overwrite
just rewrote holds fresh,
+ * complete data and does not depend on a snapshot baseline, so dropping that
baseline is intended,
+ * not an orphan. Only the trigger site knows which partitions were freshly
written; a standalone
+ * drop or a rollback sets nothing here, so the pre-callback keeps rejecting
genuinely stranded
+ * followers.
+ */
+final class ChainTableOverwriteScope {
+
+ private static final ThreadLocal<Set<BinaryRow>>
FRESHLY_WRITTEN_DELTA_PARTITIONS =
+ new ThreadLocal<>();
+
+ private ChainTableOverwriteScope() {}
+
+ /**
+ * Installs {@code partitions} as the freshly-written set and returns
whatever was installed
+ * before, so the caller restores it in a finally rather than clearing
unconditionally.
+ * Restoring keeps the scheme correct even if the truncate ever nests
another chain overwrite on
+ * the same thread.
+ */
+ static Set<BinaryRow> setFreshlyWrittenDeltaPartitions(Set<BinaryRow>
partitions) {
+ Set<BinaryRow> previous = FRESHLY_WRITTEN_DELTA_PARTITIONS.get();
+ FRESHLY_WRITTEN_DELTA_PARTITIONS.set(partitions);
+ return previous;
+ }
+
+ static void restore(Set<BinaryRow> previous) {
+ if (previous == null) {
+ FRESHLY_WRITTEN_DELTA_PARTITIONS.remove();
+ } else {
+ FRESHLY_WRITTEN_DELTA_PARTITIONS.set(previous);
+ }
+ }
+
+ static Set<BinaryRow> freshlyWrittenDeltaPartitions() {
+ Set<BinaryRow> partitions = FRESHLY_WRITTEN_DELTA_PARTITIONS.get();
+ return partitions == null
+ ? Collections.emptySet()
+ : Collections.unmodifiableSet(partitions);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/ChainTablePartitionExpireTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/ChainTablePartitionExpireTest.java
index c06b090662..ac7176dcbf 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/ChainTablePartitionExpireTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/ChainTablePartitionExpireTest.java
@@ -637,6 +637,84 @@ public class ChainTablePartitionExpireTest {
.isEqualTo(2L);
}
+ @Test
+ public void testRollbackRejectedWhenBatchDroppingBaselinesOfDelta() throws
Exception {
+ Path tablePath = tablePath("rollback_reject_batch_baseline");
+ createChainTable(tablePath, true);
+
+ FileStoreTable snapshotTable =
loadTable(tablePath).switchToBranch("snapshot");
+ FileStoreTable deltaTable =
loadTable(tablePath).switchToBranch("delta");
+
+ writeGrouped(snapshotTable, "US", "20250101", "v1"); // snapshot #1,
unrelated group
+ writeGrouped(snapshotTable, "CN", "20250201", "v2"); // snapshot #2,
CN baseline
+ writeGrouped(snapshotTable, "CN", "20250301", "v3"); // snapshot #3,
CN baseline
+ // a delta partition anchored on CN/20250301
+ writeGrouped(deltaTable, "CN", "20250315", "v4");
+
+ // Rolling back to snapshot #1 drops CN/20250201 and CN/20250301 in
ONE pure-delete
+ // commit. Validating CN/20250301 against the PRE-commit partition
list wrongly
+ // accepts it: CN/20250201 (dropped by the same commit) still counts
as its
+ // predecessor, and after the commit the delta has no baseline at all.
+ FileStoreTable snapshotBranch =
loadTable(tablePath).switchToBranch("snapshot");
+ Snapshot target = snapshotBranch.snapshotManager().snapshot(1);
+ String protectionTag = "rollback-to-as-latest-" + target.id() + "-" +
UUID.randomUUID();
+ snapshotBranch
+ .tagManager()
+ .createTag(target, protectionTag, null,
Collections.emptyList(), false);
+ try (TableCommitImpl commit = snapshotBranch.newCommit(commitUser)) {
+ assertThatThrownBy(
+ () ->
+ commit.rollbackToAsLatest(
+
snapshotBranch.tagManager().getOrThrow(protectionTag)))
+ .hasMessageContaining("Snapshot partition cannot be
dropped");
+ }
+ // The dangerous rollback was aborted, so the latest snapshot is
unchanged.
+ assertThat(
+ loadTable(tablePath)
+ .switchToBranch("snapshot")
+ .snapshotManager()
+ .latestSnapshotId())
+ .isEqualTo(3L);
+ }
+
+ @Test
+ public void testRollbackAllowedWhenPartitionOnlyPartiallyDeleted() throws
Exception {
+ Path tablePath = tablePath("rollback_partial_partition");
+ createChainTable(tablePath, true);
+
+ FileStoreTable snapshotTable =
loadTable(tablePath).switchToBranch("snapshot");
+ FileStoreTable deltaTable =
loadTable(tablePath).switchToBranch("delta");
+
+ writeGrouped(snapshotTable, "US", "20250101", "v1"); // snapshot #1
+ writeGrouped(snapshotTable, "CN", "20250201", "v2"); // snapshot #2,
first file
+ writeGrouped(snapshotTable, "CN", "20250201", "v3"); // snapshot #3,
second file
+ writeGrouped(snapshotTable, "CN", "20250301", "v4"); // snapshot #4
+ // a delta partition anchored on CN/20250301
+ writeGrouped(deltaTable, "CN", "20250315", "v5");
+
+ // Rolling back to snapshot #2 deletes the second CN/20250201 file
(the partition
+ // itself survives with its first file) and fully drops CN/20250301.
The surviving
+ // CN/20250201 must still count as the baseline of CN/20250315, so the
rollback is
+ // safe and must not be vetoed.
+ FileStoreTable snapshotBranch =
loadTable(tablePath).switchToBranch("snapshot");
+ Snapshot target = snapshotBranch.snapshotManager().snapshot(2);
+ String protectionTag = "rollback-to-as-latest-" + target.id() + "-" +
UUID.randomUUID();
+ snapshotBranch
+ .tagManager()
+ .createTag(target, protectionTag, null,
Collections.emptyList(), false);
+ try (TableCommitImpl commit = snapshotBranch.newCommit(commitUser)) {
+
commit.rollbackToAsLatest(snapshotBranch.tagManager().getOrThrow(protectionTag));
+ }
+ assertThat(
+ loadTable(tablePath)
+ .switchToBranch("snapshot")
+ .snapshotManager()
+ .latestSnapshotId())
+ .isEqualTo(5L);
+
assertThat(listGroupedPartitions(loadTable(tablePath).switchToBranch("snapshot")))
+ .containsExactly("CN|20250201", "US|20250101");
+ }
+
private Path tablePath(String tableName) {
return new Path(tempDir.toUri().toString(), tableName);
}