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 1d717f01e0 [flink] Add rollback_to_as_latest procedure (#8139)
1d717f01e0 is described below

commit 1d717f01e040da5b7b41900b299cf93f0b727ec1
Author: Xiangyi Zhu <[email protected]>
AuthorDate: Fri Jul 3 20:18:21 2026 +0800

    [flink] Add rollback_to_as_latest procedure (#8139)
    
    This PR adds a non-destructive rollback procedure for Flink:
    
    ```sql
    CALL sys.rollback_to_as_latest(`table` => 'default.T', snapshot_id => 3);
    CALL sys.rollback_to_as_latest(`table` => 'default.T', tag => 'tag-1');
    ```
    
    Unlike `rollback_to`, this procedure rolls the table back to the state
    of a target snapshot or tag by materializing it as a new latest
    snapshot. Later snapshots and tags are preserved.
---
 docs/docs/flink/procedures.md                      |  30 +++
 docs/docs/maintenance/manage-snapshots.mdx         |  29 ++-
 docs/docs/maintenance/manage-tags.mdx              |  19 ++
 .../apache/paimon/operation/FileStoreCommit.java   |   3 +
 .../paimon/operation/FileStoreCommitImpl.java      | 155 +++++++++++++++
 .../apache/paimon/table/sink/TableCommitImpl.java  |  15 ++
 .../operation/ChainTablePartitionExpireTest.java   |  35 ++++
 .../paimon/operation/FileStoreCommitTest.java      | 103 ++++++++++
 .../procedure/RollbackToAsLatestProcedure.java     | 134 +++++++++++++
 .../services/org.apache.paimon.factories.Factory   |   3 +-
 .../RollbackToAsLatestProcedureITCase.java         | 210 +++++++++++++++++++++
 11 files changed, 734 insertions(+), 2 deletions(-)

diff --git a/docs/docs/flink/procedures.md b/docs/docs/flink/procedures.md
index b8726ebbd0..a0e43efbc3 100644
--- a/docs/docs/flink/procedures.md
+++ b/docs/docs/flink/procedures.md
@@ -498,6 +498,36 @@ All available procedures are listed below.
          CALL sys.rollback_to(`table` => 'default.T', snapshot_id => 10)
       </td>
    </tr>
+   <tr>
+      <td>rollback_to_as_latest</td>
+      <td>
+         -- for Flink 1.18<br/>
+         -- roll back to a snapshot as the latest snapshot<br/>
+         CALL [catalog.]sys.rollback_to_as_latest('identifier', cast(null as 
string), snapshotId)<br/><br/>
+         -- roll back to a tag as the latest snapshot<br/>
+         CALL [catalog.]sys.rollback_to_as_latest('identifier', 'tagName', 
cast(null as bigint))<br/><br/>
+         -- for Flink 1.19 and later<br/>
+         -- roll back to a snapshot as the latest snapshot<br/>
+         CALL [catalog.]sys.rollback_to_as_latest(`table` => 'identifier', 
snapshot_id => snapshotId)<br/><br/>
+         -- roll back to a tag as the latest snapshot<br/>
+         CALL [catalog.]sys.rollback_to_as_latest(`table` => 'identifier', tag 
=> 'tagName')
+      </td>
+      <td>
+         To roll a table back to a specific version and materialize it as the 
latest snapshot, without deleting later
+         snapshots or tags. Batch and time-travel reads are correct; for 
deletion-vector tables, a rollback whose only
+         difference is a deletion-vector change is not guaranteed to be 
observed by streaming overwrite readers.
+         Argument:
+            <li>table: the target table identifier. Cannot be empty.</li>
+            <li>snapshotId (Long): id of the snapshot to roll back to.</li>
+            <li>tagName: name of the tag to roll back to.</li>
+      </td>
+      <td>
+         -- for Flink 1.18<br/>
+         CALL sys.rollback_to_as_latest('default.T', cast(null as string), 
10)<br/><br/>
+         -- for Flink 1.19 and later<br/>
+         CALL sys.rollback_to_as_latest(`table` => 'default.T', snapshot_id => 
10)
+      </td>
+   </tr>
    <tr>
       <td>rollback_to_timestamp</td>
       <td>
diff --git a/docs/docs/maintenance/manage-snapshots.mdx 
b/docs/docs/maintenance/manage-snapshots.mdx
index db2d9095be..69b972cb26 100644
--- a/docs/docs/maintenance/manage-snapshots.mdx
+++ b/docs/docs/maintenance/manage-snapshots.mdx
@@ -362,6 +362,33 @@ CALL sys.rollback(table => 'database_name.table_name', 
snapshot => snasphot_id);
 
 </Tabs>
 
+## Rollback to Snapshot as Latest
+
+Roll a table back to the state of a specific snapshot id by materializing that 
state as a new latest snapshot. Unlike
+`rollback_to`, this operation does not delete snapshots or tags whose snapshot 
id is larger than the target snapshot.
+
+<Tabs groupId="rollback-to-as-latest">
+
+<TabItem value="flink-sql" label="Flink SQL">
+
+Run the following command:
+
+```sql
+CALL sys.rollback_to_as_latest(`table` => 'database_name.table_name', 
snapshot_id => <snapshot-id>);
+```
+
+</TabItem>
+
+</Tabs>
+
+:::note
+Batch and time-travel reads of the new latest snapshot are correct, because it 
points to the target snapshot's index
+manifest. However, for tables using deletion vectors, a rollback whose only 
difference from the current latest is a
+deletion-vector change (the data files are identical) produces an empty data 
delta, so it is not guaranteed to be
+observed by streaming overwrite readers — the same as a plain deletion-vector 
delete, which is only streamable through a
+changelog producer.
+:::
+
 ## Remove Orphan Files
 
 Paimon files are deleted physically only when expiring snapshots. However, it 
is possible that some unexpected errors occurred
@@ -411,4 +438,4 @@ The table can be `*` to clean all tables in the database.
 
 </TabItem>
 
-</Tabs>
\ No newline at end of file
+</Tabs>
diff --git a/docs/docs/maintenance/manage-tags.mdx 
b/docs/docs/maintenance/manage-tags.mdx
index 78e588e5f5..f53538c3a8 100644
--- a/docs/docs/maintenance/manage-tags.mdx
+++ b/docs/docs/maintenance/manage-tags.mdx
@@ -300,3 +300,22 @@ CALL sys.rollback(table => 'test.t', version => '2');
 </TabItem>
 
 </Tabs>
+
+## Rollback to Tag as Latest
+
+Roll a table back to the state of a specific tag by materializing that state 
as a new latest snapshot. Unlike
+`rollback_to`, this operation does not delete snapshots or tags whose snapshot 
id is larger than the target tag.
+
+<Tabs groupId="rollback-to-as-latest">
+
+<TabItem value="flink-sql" label="Flink SQL">
+
+Run the following command:
+
+```sql
+CALL sys.rollback_to_as_latest(`table` => 'database_name.table_name', tag => 
'tag_name');
+```
+
+</TabItem>
+
+</Tabs>
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
index 2dab92854c..42211d56ca 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java
@@ -79,6 +79,9 @@ public interface FileStoreCommit extends AutoCloseable {
     /** Compact the manifest entries only. */
     void compactManifest();
 
+    /** Roll back to the target snapshot and materialize it as the latest 
snapshot. */
+    boolean rollbackToAsLatest(Snapshot targetSnapshot);
+
     /** Abort an unsuccessful commit. The data files will be deleted. */
     void abort(List<CommitMessage> commitMessages);
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index 6495d10c27..d5807e33d8 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -111,6 +111,7 @@ import static 
org.apache.paimon.operation.commit.RowTrackingCommitUtils.assignRo
 import static 
org.apache.paimon.partition.PartitionPredicate.createBinaryPartitions;
 import static 
org.apache.paimon.partition.PartitionPredicate.createPartitionPredicate;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
 /**
  * Default implementation of {@link FileStoreCommit}.
@@ -1287,6 +1288,160 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         return commitSnapshotImpl(newSnapshot, emptyList());
     }
 
+    @Override
+    public boolean rollbackToAsLatest(Snapshot targetSnapshot) {
+        Snapshot latest =
+                checkNotNull(
+                        snapshotManager.latestSnapshot(),
+                        "Latest snapshot is null, can not roll back.");
+
+        Map<FileEntry.Identifier, ManifestEntry> latestEntries = new 
HashMap<>();
+        FileEntry.mergeEntries(
+                manifestFile,
+                manifestList.readDataManifests(latest),
+                latestEntries,
+                options.scanManifestParallelism());
+
+        latestEntries.entrySet().removeIf(entry -> entry.getValue().kind() != 
FileKind.ADD);
+
+        Map<FileEntry.Identifier, ManifestEntry> targetEntries = new 
HashMap<>();
+        FileEntry.mergeEntries(
+                manifestFile,
+                manifestList.readDataManifests(targetSnapshot),
+                targetEntries,
+                options.scanManifestParallelism());
+        targetEntries.entrySet().removeIf(entry -> entry.getValue().kind() != 
FileKind.ADD);
+
+        List<ManifestEntry> deltaFiles = new ArrayList<>();
+        for (Map.Entry<FileEntry.Identifier, ManifestEntry> entry : 
latestEntries.entrySet()) {
+            if (!targetEntries.containsKey(entry.getKey())) {
+                ManifestEntry manifestEntry = entry.getValue();
+                deltaFiles.add(
+                        ManifestEntry.create(
+                                FileKind.DELETE,
+                                manifestEntry.partition(),
+                                manifestEntry.bucket(),
+                                manifestEntry.totalBuckets(),
+                                manifestEntry.file()));
+            }
+        }
+        for (Map.Entry<FileEntry.Identifier, ManifestEntry> entry : 
targetEntries.entrySet()) {
+            if (!latestEntries.containsKey(entry.getKey())) {
+                ManifestEntry manifestEntry = entry.getValue();
+                deltaFiles.add(
+                        ManifestEntry.create(
+                                FileKind.ADD,
+                                manifestEntry.partition(),
+                                manifestEntry.bucket(),
+                                manifestEntry.totalBuckets(),
+                                manifestEntry.file()));
+            }
+        }
+
+        Pair<String, Long> baseManifestList =
+                manifestList.write(manifestFile.write(new 
ArrayList<>(latestEntries.values())));
+        Pair<String, Long> deltaManifestList = 
manifestList.write(manifestFile.write(deltaFiles));
+        // For row-tracking tables nextRowId must stay monotonic: a rollback 
to an older snapshot
+        // must not move it backwards, otherwise new appends would reuse row 
ids already assigned by
+        // the snapshots between the target and the previous latest, breaking 
the global uniqueness
+        // of _ROW_ID. Keep the larger of the previous latest and the target 
nextRowId.
+        Long nextRowId = maxNextRowId(latest.nextRowId(), 
targetSnapshot.nextRowId());
+        Snapshot newSnapshot =
+                new Snapshot(
+                        latest.id() + 1,
+                        targetSnapshot.schemaId(),
+                        baseManifestList.getKey(),
+                        baseManifestList.getRight(),
+                        deltaManifestList.getKey(),
+                        deltaManifestList.getRight(),
+                        null,
+                        null,
+                        targetSnapshot.indexManifest(),
+                        commitUser,
+                        Long.MAX_VALUE,
+                        CommitKind.OVERWRITE,
+                        System.currentTimeMillis(),
+                        targetSnapshot.totalRecordCount(),
+                        recordCountAdd(deltaFiles) - 
recordCountDelete(deltaFiles),
+                        null,
+                        targetSnapshot.watermark(),
+                        targetSnapshot.statistics(),
+                        targetSnapshot.properties(),
+                        nextRowId);
+
+        // The rollback is an overwrite from the previous latest to the 
target, so the base files,
+        // delta files and index changes describe the transition the callbacks 
need. These are
+        // shared by the pre- and post-commit callbacks below.
+        List<SimpleFileEntry> baseFiles =
+                SimpleFileEntry.from(new ArrayList<>(latestEntries.values()));
+        List<IndexManifestEntry> indexChanges = rollbackIndexChanges(latest, 
targetSnapshot);
+
+        // Like a regular commit, run the pre-commit callbacks before the 
snapshot becomes visible.
+        // They may veto the rollback by throwing (e.g. a chain-table snapshot 
branch rejects a
+        // pure-DELETE overwrite that would drop a snapshot partition still 
anchoring delta
+        // partitions), in which case the rollback snapshot is never created.
+        commitPreCallbacks.forEach(
+                callback -> callback.call(baseFiles, deltaFiles, indexChanges, 
newSnapshot));
+
+        boolean success =
+                commitSnapshotImpl(newSnapshot, new 
ArrayList<>(PartitionEntry.merge(deltaFiles)));
+        if (success) {
+            // Notify the post-commit callbacks so external views stay in sync 
with the rolled-back
+            // state (e.g. Iceberg compatibility metadata and chain-table 
overwrite handling).
+            CommitCallback.Context context =
+                    new CommitCallback.Context(
+                            baseFiles,
+                            deltaFiles,
+                            indexChanges,
+                            newSnapshot,
+                            newSnapshot.commitIdentifier());
+            commitCallbacks.forEach(callback -> callback.call(context));
+        }
+        return success;
+    }
+
+    /**
+     * Computes the index file changes between the previous latest snapshot 
and the rollback target,
+     * mirroring how the data delta files are derived: entries that only exist 
in the previous
+     * latest are marked as {@link FileKind#DELETE}, entries that only exist 
in the target are kept
+     * as ADD.
+     */
+    private List<IndexManifestEntry> rollbackIndexChanges(Snapshot latest, 
Snapshot target) {
+        Set<IndexManifestEntry> latestIndexEntries = 
readIndexEntries(latest.indexManifest());
+        Set<IndexManifestEntry> targetIndexEntries = 
readIndexEntries(target.indexManifest());
+
+        List<IndexManifestEntry> indexChanges = new ArrayList<>();
+        for (IndexManifestEntry entry : latestIndexEntries) {
+            if (!targetIndexEntries.contains(entry)) {
+                indexChanges.add(entry.toDeleteEntry());
+            }
+        }
+        for (IndexManifestEntry entry : targetIndexEntries) {
+            if (!latestIndexEntries.contains(entry)) {
+                indexChanges.add(entry);
+            }
+        }
+        return indexChanges;
+    }
+
+    private Set<IndexManifestEntry> readIndexEntries(@Nullable String 
indexManifest) {
+        if (indexManifest == null) {
+            return Collections.emptySet();
+        }
+        return new HashSet<>(indexManifestFile.read(indexManifest));
+    }
+
+    @Nullable
+    private static Long maxNextRowId(@Nullable Long left, @Nullable Long 
right) {
+        if (left == null) {
+            return right;
+        }
+        if (right == null) {
+            return left;
+        }
+        return Math.max(left, right);
+    }
+
     public void compactManifest() {
         int retryCount = 0;
         long startMillis = System.currentTimeMillis();
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 9ec742b354..e0ee31b21e 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
@@ -219,6 +219,21 @@ public class TableCommitImpl implements InnerTableCommit {
         commit.compactManifest();
     }
 
+    public boolean rollbackToAsLatest(Snapshot targetSnapshot) {
+        checkCommitted();
+        boolean success = commit.rollbackToAsLatest(targetSnapshot);
+        if (success) {
+            // Skip automatic expiration for the rollback path. 
rollback_to_as_latest promises not
+            // to
+            // delete snapshots or tags whose snapshot id is larger than the 
target snapshot, but
+            // the newly committed latest snapshot would otherwise let 
expiration (e.g. a low
+            // snapshot.num-retained.max) immediately remove the rolled-back 
snapshot and the later
+            // snapshots/tags it is meant to preserve.
+            maintain(COMMIT_IDENTIFIER, maintainExecutor, false);
+        }
+        return success;
+    }
+
     private void checkCommitted() {
         checkState(!batchCommitted, "BatchTableCommit only support one-time 
committing.");
         batchCommitted = true;
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 5ff5edbf71..0094addcf7 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
@@ -19,6 +19,7 @@
 package org.apache.paimon.operation;
 
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryString;
@@ -594,6 +595,40 @@ public class ChainTablePartitionExpireTest {
                                         "Partition " + region + "|" + dt + " 
not found"));
     }
 
+    @Test
+    public void 
testRollbackToAsLatestRejectedWhenDroppingAnchorSnapshotPartition()
+            throws Exception {
+        Path tablePath = tablePath("rollback_reject_anchor");
+        createChainTable(tablePath, true);
+
+        FileStoreTable snapshotTable = 
loadTable(tablePath).switchToBranch("snapshot");
+        FileStoreTable deltaTable = 
loadTable(tablePath).switchToBranch("delta");
+
+        // snapshot branch: an unrelated group (US) first, then the CN anchor.
+        writeGrouped(snapshotTable, "US", "20250101", "v1"); // snapshot 
branch snapshot #1
+        writeGrouped(snapshotTable, "CN", "20250301", "v2"); // snapshot #2 
adds CN/20250301
+
+        // delta branch: a CN delta that uses CN/20250301 as its only anchor.
+        writeGrouped(deltaTable, "CN", "20250315", "v3");
+
+        // Rolling back the snapshot branch to snapshot #1 would drop 
CN/20250301, the only anchor
+        // of the CN/20250315 delta. The pre-commit callback must reject this 
rollback (same as a
+        // regular overwrite) instead of silently breaking the chain.
+        FileStoreTable snapshotBranch = 
loadTable(tablePath).switchToBranch("snapshot");
+        Snapshot target = snapshotBranch.snapshotManager().snapshot(1);
+        try (TableCommitImpl commit = snapshotBranch.newCommit(commitUser)) {
+            assertThatThrownBy(() -> commit.rollbackToAsLatest(target))
+                    .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(2L);
+    }
+
     private Path tablePath(String tableName) {
         return new Path(tempDir.toUri().toString(), tableName);
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
index 5a386947c1..e2210edea2 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
@@ -56,8 +56,12 @@ import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.stats.ColStats;
 import org.apache.paimon.stats.Statistics;
 import org.apache.paimon.stats.StatsFileHandler;
+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.source.IncrementalSplit;
+import org.apache.paimon.table.source.Split;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowKind;
@@ -953,6 +957,105 @@ public class FileStoreCommitTest {
         assertThat(dvs.get("f2").isDeleted(3)).isTrue();
     }
 
+    @Test
+    public void testRollbackToAsLatestFileLevelDeleteIsVisibleToStreaming() 
throws Exception {
+        // Contrast with the DV-only case: when a rollback removes whole data 
files, the delete is
+        // file-level (FileKind.DELETE) and IS visible to streaming readers, 
no DV needed.
+        TestAppendFileStore store = 
TestAppendFileStore.createAppendStore(tempDir, new HashMap<>());
+        BinaryRow partition = gen.getPartition(gen.next());
+
+        // snapshot 1 (target): f1
+        store.commit(store.writeDataFiles(partition, 0, 
Collections.singletonList("f1")));
+        Snapshot target = store.snapshotManager().latestSnapshot();
+
+        // snapshot 2 (latest): add f2
+        store.commit(store.writeDataFiles(partition, 0, 
Collections.singletonList("f2")));
+
+        // roll back to snapshot 1 — f2 must be removed
+        try (FileStoreCommitImpl commit = store.newCommit()) {
+            assertThat(commit.rollbackToAsLatest(target)).isTrue();
+        }
+        Snapshot rolledBack = store.snapshotManager().latestSnapshot();
+
+        String root = TraceableFileIO.SCHEME + "://" + tempDir.toString();
+        FileStoreTable table = FileStoreTableFactory.create(store.fileIO(), 
new Path(root));
+        List<Split> splits =
+                
table.newSnapshotReader().withSnapshot(rolledBack).readChanges().splits();
+
+        // f2 is retracted (before side) — the file-level delete is visible to 
streaming
+        assertThat(splits).isNotEmpty();
+        IncrementalSplit split = (IncrementalSplit) splits.get(0);
+        boolean f2Retracted = split.beforeFiles().stream().anyMatch(f -> 
f.fileName().equals("f2"));
+        assertThat(f2Retracted).isTrue();
+    }
+
+    @Test
+    public void testNormalDeletionVectorDeleteIsInvisibleToStreamingDelta() 
throws Exception {
+        // Baseline (no rollback involved): without a changelog producer, a 
plain DV-only delete
+        // produces an empty data delta, so the streaming delta read sees no 
change at all. DV
+        // deletes are only streamable via a changelog producer, not via the 
delta/overwrite path.
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        TestAppendFileStore store = 
TestAppendFileStore.createAppendStore(tempDir, options);
+        BinaryRow partition = gen.getPartition(gen.next());
+
+        // snapshot 1: data file f1, no deletion vectors
+        store.commit(store.writeDataFiles(partition, 0, 
Collections.singletonList("f1")));
+
+        // snapshot 2: a DV-only delete on f1 (data file unchanged, only an 
index increment)
+        store.commit(
+                store.writeDVIndexFiles(
+                        partition, 0, Collections.singletonMap("f1", 
Arrays.asList(1, 3))));
+        Snapshot dvDelete = store.snapshotManager().latestSnapshot();
+        assertThat(store.scanDVIndexFiles(partition, 0)).isNotEmpty();
+
+        String root = TraceableFileIO.SCHEME + "://" + tempDir.toString();
+        FileStoreTable table = FileStoreTableFactory.create(store.fileIO(), 
new Path(root));
+        List<Split> splits =
+                
table.newSnapshotReader().withSnapshot(dvDelete).readChanges().splits();
+        assertThat(splits).isEmpty();
+    }
+
+    @Test
+    public void 
testRollbackToAsLatestDeletionVectorChangeIsInvisibleToStreaming()
+            throws Exception {
+        // A DV-only rollback changes only the index manifest (the data files 
are identical), so it
+        // produces an empty data delta and is invisible to streaming readers 
— consistent with a
+        // plain DV delete, which is also invisible to streaming (DV deletes 
are only streamable via
+        // a changelog producer, not via the delta/overwrite path).
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        TestAppendFileStore store = 
TestAppendFileStore.createAppendStore(tempDir, options);
+        BinaryRow partition = gen.getPartition(gen.next());
+
+        // snapshot 1: data file f1, no deletion vectors (the rollback target)
+        store.commit(store.writeDataFiles(partition, 0, 
Collections.singletonList("f1")));
+        Snapshot target = store.snapshotManager().latestSnapshot();
+
+        // snapshot 2: DV-only change — add a deletion vector for f1, data 
file unchanged
+        store.commit(
+                store.writeDVIndexFiles(
+                        partition, 0, Collections.singletonMap("f1", 
Arrays.asList(1, 3))));
+        // sanity check: f1 really has a deletion vector now
+        assertThat(store.scanDVIndexFiles(partition, 0)).isNotEmpty();
+
+        // snapshot 3: roll back to snapshot 1
+        try (FileStoreCommitImpl commit = store.newCommit()) {
+            assertThat(commit.rollbackToAsLatest(target)).isTrue();
+        }
+        Snapshot rolledBack = store.snapshotManager().latestSnapshot();
+
+        // The rollback's data delta is empty, so the streaming change read 
produces no splits — the
+        // DV-only rollback is invisible to streaming (batch / time-travel 
reads remain correct
+        // since
+        // the snapshot points to the target's index manifest).
+        String root = TraceableFileIO.SCHEME + "://" + tempDir.toString();
+        FileStoreTable table = FileStoreTableFactory.create(store.fileIO(), 
new Path(root));
+        List<Split> splits =
+                
table.newSnapshotReader().withSnapshot(rolledBack).readChanges().splits();
+        assertThat(splits).isEmpty();
+    }
+
     @Test
     public void testManifestCompact() throws Exception {
         TestFileStore store = createStore(false);
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/RollbackToAsLatestProcedure.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/RollbackToAsLatestProcedure.java
new file mode 100644
index 0000000000..af1acf618b
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/RollbackToAsLatestProcedure.java
@@ -0,0 +1,134 @@
+/*
+ * 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.procedure;
+
+import org.apache.paimon.FileStore;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.sink.TableCommitImpl;
+import org.apache.paimon.utils.Preconditions;
+import org.apache.paimon.utils.SnapshotManager;
+import org.apache.paimon.utils.StringUtils;
+
+import org.apache.flink.table.annotation.ArgumentHint;
+import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.ProcedureHint;
+import org.apache.flink.table.procedure.ProcedureContext;
+import org.apache.flink.types.Row;
+
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.UUID;
+
+/**
+ * Rollback to as latest procedure. Usage:
+ *
+ * <pre><code>
+ *  -- roll back to a snapshot as the latest snapshot
+ *  CALL sys.rollback_to_as_latest(`table` => 'tableId', snapshot_id => 
snapshotId)
+ *
+ *  -- roll back to a tag as the latest snapshot
+ *  CALL sys.rollback_to_as_latest(`table` => 'tableId', tag => 'tagName')
+ * </code></pre>
+ */
+public class RollbackToAsLatestProcedure extends ProcedureBase {
+
+    public static final String IDENTIFIER = "rollback_to_as_latest";
+
+    @ProcedureHint(
+            argument = {
+                @ArgumentHint(name = "table", type = @DataTypeHint("STRING")),
+                @ArgumentHint(name = "tag", type = @DataTypeHint("STRING"), 
isOptional = true),
+                @ArgumentHint(
+                        name = "snapshot_id",
+                        type = @DataTypeHint("BIGINT"),
+                        isOptional = true)
+            })
+    public @DataTypeHint(
+            "ROW<previous_snapshot_id BIGINT, rolled_back_snapshot_id BIGINT, 
current_snapshot_id BIGINT>")
+    Row[] call(ProcedureContext procedureContext, String tableId, String 
tagName, Long snapshotId)
+            throws Catalog.TableNotExistException {
+        Table table = catalog.getTable(Identifier.fromString(tableId));
+        FileStoreTable fileStoreTable = (FileStoreTable) table;
+
+        FileStore<?> store = fileStoreTable.store();
+        Snapshot latestSnapshot = store.snapshotManager().latestSnapshot();
+        Preconditions.checkNotNull(latestSnapshot, "Latest snapshot is null, 
can not roll back.");
+
+        boolean hasTag = !StringUtils.isNullOrWhitespaceOnly(tagName);
+        boolean hasSnapshot = snapshotId != null;
+        Preconditions.checkArgument(
+                hasTag != hasSnapshot, "Must specify exactly one of tag and 
snapshot_id.");
+
+        Snapshot targetSnapshot;
+        if (hasTag) {
+            targetSnapshot = 
store.newTagManager().getOrThrow(tagName).trimToSnapshot();
+        } else {
+            targetSnapshot = findSnapshot(store, snapshotId);
+        }
+
+        try (TableCommitImpl commit =
+                fileStoreTable.newCommit("rollback-to-as-latest-" + 
UUID.randomUUID().toString())) {
+            Preconditions.checkState(
+                    commit.rollbackToAsLatest(targetSnapshot),
+                    "Failed to roll back to snapshot %s as latest.",
+                    targetSnapshot.id());
+        } catch (Exception e) {
+            throw new RuntimeException(
+                    String.format(
+                            "Failed to roll back to snapshot %s as latest.", 
targetSnapshot.id()),
+                    e);
+        }
+
+        return new Row[] {
+            Row.of(
+                    latestSnapshot.id(),
+                    targetSnapshot.id(),
+                    store.snapshotManager().latestSnapshotId())
+        };
+    }
+
+    private Snapshot findSnapshot(FileStore<?> store, long snapshotId) {
+        SnapshotManager snapshotManager = store.snapshotManager();
+        if (snapshotManager.snapshotExists(snapshotId)) {
+            return snapshotManager.snapshot(snapshotId);
+        }
+
+        SortedMap<Snapshot, List<String>> tags = store.newTagManager().tags();
+        for (Map.Entry<Snapshot, List<String>> entry : tags.entrySet()) {
+            if (entry.getKey().id() == snapshotId) {
+                return entry.getKey();
+            } else if (entry.getKey().id() > snapshotId) {
+                break;
+            }
+        }
+
+        throw new IllegalArgumentException(
+                String.format("Snapshot '%s' to roll back to doesn't exist.", 
snapshotId));
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
 
b/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
index db2777a3a0..26a1a2b9cb 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
+++ 
b/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
@@ -69,6 +69,7 @@ org.apache.paimon.flink.procedure.DropPartitionProcedure
 org.apache.paimon.flink.procedure.MergeIntoProcedure
 org.apache.paimon.flink.procedure.ResetConsumerProcedure
 org.apache.paimon.flink.procedure.RollbackToProcedure
+org.apache.paimon.flink.procedure.RollbackToAsLatestProcedure
 org.apache.paimon.flink.procedure.RollbackToTimestampProcedure
 org.apache.paimon.flink.procedure.RollbackToWatermarkProcedure
 org.apache.paimon.flink.procedure.MigrateTableProcedure
@@ -106,4 +107,4 @@ 
org.apache.paimon.flink.procedure.DataEvolutionMergeIntoProcedure
 org.apache.paimon.flink.procedure.ReassignRowIdProcedure
 org.apache.paimon.flink.procedure.CreateGlobalIndexProcedure
 org.apache.paimon.flink.procedure.VectorSearchProcedure
-org.apache.paimon.flink.procedure.DropGlobalIndexProcedure
\ No newline at end of file
+org.apache.paimon.flink.procedure.DropGlobalIndexProcedure
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/RollbackToAsLatestProcedureITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/RollbackToAsLatestProcedureITCase.java
new file mode 100644
index 0000000000..383e9334e0
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/RollbackToAsLatestProcedureITCase.java
@@ -0,0 +1,210 @@
+/*
+ * 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.procedure;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.flink.CatalogITCaseBase;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.manifest.ManifestFileMeta;
+import org.apache.paimon.manifest.ManifestList;
+import org.apache.paimon.table.FileStoreTable;
+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.utils.SnapshotManager;
+
+import org.apache.flink.types.Row;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** IT cases for rollback_to_as_latest procedure. */
+public class RollbackToAsLatestProcedureITCase extends CatalogITCaseBase {
+
+    @Test
+    public void testRollbackToSnapshotAsLatest() throws Exception {
+        sql("CREATE TABLE T (id INT, name STRING)");
+
+        FileStoreTable table = paimonTable("T");
+        SnapshotManager snapshotManager = table.snapshotManager();
+
+        commitRow(table, 1, "a");
+        commitRow(table, 2, "b");
+        commitRow(table, 3, "c");
+        assertEquals(3, snapshotManager.latestSnapshotId());
+
+        assertThat(sql("CALL sys.rollback_to_as_latest(`table` => 'default.T', 
snapshot_id => 1)"))
+                .containsExactly(Row.of(3L, 1L, 4L));
+
+        assertEquals(4, snapshotManager.latestSnapshotId());
+        assertRollbackDelta(table, 4, 0, 2, -2L);
+        assertTrue(snapshotManager.snapshotExists(2));
+        assertTrue(snapshotManager.snapshotExists(3));
+        assertThat(sql("SELECT * FROM T")).containsExactly(Row.of(1, "a"));
+
+        assertThat(sql("CALL sys.rollback_to_as_latest(`table` => 'default.T', 
snapshot_id => 3)"))
+                .containsExactly(Row.of(4L, 3L, 5L));
+
+        assertEquals(5, snapshotManager.latestSnapshotId());
+        assertRollbackDelta(table, 5, 2, 0, 2L);
+        assertThat(sql("SELECT * FROM T"))
+                .containsExactlyInAnyOrder(Row.of(1, "a"), Row.of(2, "b"), 
Row.of(3, "c"));
+
+        commitRow(table, 4, "d");
+        assertEquals(6, snapshotManager.latestSnapshotId());
+        assertThat(sql("SELECT * FROM T"))
+                .containsExactlyInAnyOrder(
+                        Row.of(1, "a"), Row.of(2, "b"), Row.of(3, "c"), 
Row.of(4, "d"));
+    }
+
+    @Test
+    public void testRollbackToTagAsLatest() throws Exception {
+        sql("CREATE TABLE T (id INT, name STRING)");
+
+        FileStoreTable table = paimonTable("T");
+        SnapshotManager snapshotManager = table.snapshotManager();
+
+        commitRow(table, 1, "a");
+        commitRow(table, 2, "b");
+        commitRow(table, 3, "c");
+        assertEquals(3, snapshotManager.latestSnapshotId());
+
+        sql("CALL sys.create_tag(`table` => 'default.T', tag => 'tag-1', 
snapshot_id => 1)");
+
+        assertThat(sql("CALL sys.rollback_to_as_latest(`table` => 'default.T', 
tag => 'tag-1')"))
+                .containsExactly(Row.of(3L, 1L, 4L));
+
+        assertEquals(4, snapshotManager.latestSnapshotId());
+        assertRollbackDelta(table, 4, 0, 2, -2L);
+        assertTrue(snapshotManager.snapshotExists(2));
+        assertTrue(snapshotManager.snapshotExists(3));
+        assertThat(sql("SELECT * FROM T")).containsExactly(Row.of(1, "a"));
+    }
+
+    @Test
+    public void testRollbackDoesNotExpireKeptSnapshots() throws Exception {
+        sql("CREATE TABLE T (id INT, name STRING)");
+
+        FileStoreTable table = paimonTable("T");
+        SnapshotManager snapshotManager = table.snapshotManager();
+
+        commitRow(table, 1, "a");
+        commitRow(table, 2, "b");
+        commitRow(table, 3, "c");
+        assertEquals(3, snapshotManager.latestSnapshotId());
+
+        // Configure aggressive expiration that would otherwise drop every 
snapshot but the latest.
+        sql(
+                "ALTER TABLE T SET ("
+                        + "'snapshot.num-retained.min' = '1', "
+                        + "'snapshot.num-retained.max' = '1')");
+
+        assertThat(sql("CALL sys.rollback_to_as_latest(`table` => 'default.T', 
snapshot_id => 1)"))
+                .containsExactly(Row.of(3L, 1L, 4L));
+
+        // rollback_to_as_latest must not delete snapshots whose id is larger 
than the target one,
+        // even with snapshot.num-retained.max = 1. The rollback path skips 
automatic expiration.
+        assertEquals(4, snapshotManager.latestSnapshotId());
+        assertTrue(snapshotManager.snapshotExists(1));
+        assertTrue(snapshotManager.snapshotExists(2));
+        assertTrue(snapshotManager.snapshotExists(3));
+    }
+
+    @Test
+    public void testRollbackKeepsRowIdMonotonic() throws Exception {
+        sql("CREATE TABLE RT (id INT, name STRING) WITH 
('row-tracking.enabled' = 'true')");
+
+        FileStoreTable table = paimonTable("RT");
+        SnapshotManager snapshotManager = table.snapshotManager();
+
+        commitRow(table, 1, "a");
+        commitRow(table, 2, "b");
+        commitRow(table, 3, "c");
+        assertEquals(3, snapshotManager.latestSnapshotId());
+
+        Long latestNextRowId = snapshotManager.latestSnapshot().nextRowId();
+        assertThat(latestNextRowId).isNotNull();
+
+        // Roll back to the first snapshot, whose own nextRowId is smaller 
than the current latest.
+        assertThat(sql("CALL sys.rollback_to_as_latest(`table` => 
'default.RT', snapshot_id => 1)"))
+                .containsExactly(Row.of(3L, 1L, 4L));
+
+        // nextRowId must not move backwards: otherwise new appends would 
reuse row ids already
+        // assigned by snapshots 2 and 3, breaking the global uniqueness of 
_ROW_ID. The rollback
+        // snapshot keeps the larger of the previous latest and the target 
nextRowId.
+        Snapshot rolledBack = table.snapshot(4);
+        assertThat(rolledBack.nextRowId()).isEqualTo(latestNextRowId);
+    }
+
+    @Test
+    public void testRollbackTriggersCommitCallback() throws Exception {
+        sql(
+                "CREATE TABLE T (id INT, name STRING) WITH ("
+                        + "'metadata.iceberg.storage' = 'table-location')");
+
+        FileStoreTable table = paimonTable("T");
+        SnapshotManager snapshotManager = table.snapshotManager();
+
+        commitRow(table, 1, "a");
+        commitRow(table, 2, "b");
+        commitRow(table, 3, "c");
+        assertEquals(3, snapshotManager.latestSnapshotId());
+
+        assertThat(sql("CALL sys.rollback_to_as_latest(`table` => 'default.T', 
snapshot_id => 1)"))
+                .containsExactly(Row.of(3L, 1L, 4L));
+        assertEquals(4, snapshotManager.latestSnapshotId());
+
+        // The rollback must trigger the commit callbacks like a regular 
commit, so external views
+        // stay in sync with the rolled-back state. With Iceberg compatibility 
enabled, that means
+        // Iceberg metadata is generated for the rollback snapshot.
+        Path icebergMetadata = new Path(table.location(), 
"metadata/v4.metadata.json");
+        assertTrue(table.fileIO().exists(icebergMetadata));
+    }
+
+    private void assertRollbackDelta(
+            FileStoreTable table,
+            long snapshotId,
+            long expectedNumAddedFiles,
+            long expectedNumDeletedFiles,
+            long expectedDeltaRecordCount) {
+        Snapshot snapshot = table.snapshot(snapshotId);
+        ManifestList manifestList = 
table.store().manifestListFactory().create();
+        List<ManifestFileMeta> deltaManifests = 
manifestList.readDeltaManifests(snapshot);
+
+        assertThat(deltaManifests).hasSize(1);
+        
assertThat(deltaManifests.get(0).numAddedFiles()).isEqualTo(expectedNumAddedFiles);
+        
assertThat(deltaManifests.get(0).numDeletedFiles()).isEqualTo(expectedNumDeletedFiles);
+        
assertThat(snapshot.deltaRecordCount()).isEqualTo(expectedDeltaRecordCount);
+    }
+
+    private void commitRow(FileStoreTable table, int id, String name) throws 
Exception {
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            write.write(GenericRow.of(id, BinaryString.fromString(name)));
+            commit.commit(write.prepareCommit());
+        }
+    }
+}


Reply via email to