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 283f70922c [spark] Add rollback_to_as_latest procedure (#10199)
283f70922c is described below

commit 283f70922c209789c042dde2343f7d8e1eee9ad8
Author: jackylee <[email protected]>
AuthorDate: Sat Oct 3 17:07:26 2026 +0800

    [spark] Add rollback_to_as_latest procedure (#10199)
---
 docs/docs/spark/procedures.md                      |   1 +
 docs/docs/spark/procedures/versions.md             |  23 ++
 .../org/apache/paimon/spark/SparkProcedures.java   |   2 +
 .../procedure/RollbackToAsLatestProcedure.java     | 242 +++++++++++++++++++++
 .../RollbackToAsLatestProcedureTest.scala          | 186 ++++++++++++++++
 5 files changed, 454 insertions(+)

diff --git a/docs/docs/spark/procedures.md b/docs/docs/spark/procedures.md
index 2048c49b07..947de28348 100644
--- a/docs/docs/spark/procedures.md
+++ b/docs/docs/spark/procedures.md
@@ -89,6 +89,7 @@ Choose a group, then use the page contents to jump to a 
procedure:
 [`fast_forward`](./procedures/versions#fast_forward),
 [`merge_branch`](./procedures/versions#merge_branch),
 [`rollback`](./procedures/versions#rollback),
+[`rollback_to_as_latest`](./procedures/versions#rollback_to_as_latest),
 [`rollback_to_timestamp`](./procedures/versions#rollback_to_timestamp),
 [`rollback_to_watermark`](./procedures/versions#rollback_to_watermark)
 
diff --git a/docs/docs/spark/procedures/versions.md 
b/docs/docs/spark/procedures/versions.md
index d9b9672972..f4d4a76f08 100644
--- a/docs/docs/spark/procedures/versions.md
+++ b/docs/docs/spark/procedures/versions.md
@@ -240,6 +240,29 @@ CALL sys.rollback(table => 'default.T', tag => 'tag1');
 CALL sys.rollback(table => 'default.T', snapshot => 2);
 ```
 
+## rollback_to_as_latest
+
+Roll back to a retained snapshot or tag and materialize it as the latest 
snapshot, without deleting
+later snapshots or tags. Specify exactly one of `snapshot_id` or `tag`. 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.
+
+When rolling back to a snapshot, the procedure creates a tag named
+`rollback-to-as-latest-<snapshot_id>-<uuid>` that protects the restored data 
files from snapshot
+expiration.
+
+**Arguments**
+
+- `table` (`STRING`, required): the target table identifier.
+- `tag` (`STRING`, optional): tag that will roll back to.
+- `snapshot_id` (`BIGINT`, optional): snapshot that will roll back to.
+
+```sql
+CALL sys.rollback_to_as_latest(table => 'default.T', snapshot_id => 10);
+
+CALL sys.rollback_to_as_latest(table => 'default.T', tag => 'tag1');
+```
+
 ## rollback_to_timestamp
 
 Rollback to the snapshot which earlier or equal than timestamp.
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
index f534773959..18bb328375 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
@@ -67,6 +67,7 @@ import 
org.apache.paimon.spark.procedure.ResetConsumerProcedure;
 import org.apache.paimon.spark.procedure.RevokePermissionProcedure;
 import org.apache.paimon.spark.procedure.RewriteFileIndexProcedure;
 import org.apache.paimon.spark.procedure.RollbackProcedure;
+import org.apache.paimon.spark.procedure.RollbackToAsLatestProcedure;
 import org.apache.paimon.spark.procedure.RollbackToTimestampProcedure;
 import org.apache.paimon.spark.procedure.RollbackToWatermarkProcedure;
 import org.apache.paimon.spark.procedure.TriggerTagAutomaticCreationProcedure;
@@ -98,6 +99,7 @@ public class SparkProcedures {
         ImmutableMap.Builder<String, Supplier<ProcedureBuilder>> 
procedureBuilders =
                 ImmutableMap.builder();
         procedureBuilders.put("rollback", RollbackProcedure::builder);
+        procedureBuilders.put("rollback_to_as_latest", 
RollbackToAsLatestProcedure::builder);
         procedureBuilders.put("rollback_to_timestamp", 
RollbackToTimestampProcedure::builder);
         procedureBuilders.put("rollback_to_watermark", 
RollbackToWatermarkProcedure::builder);
         procedureBuilders.put("purge_files", PurgeFilesProcedure::builder);
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RollbackToAsLatestProcedure.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RollbackToAsLatestProcedure.java
new file mode 100644
index 0000000000..69366598ee
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RollbackToAsLatestProcedure.java
@@ -0,0 +1,242 @@
+/*
+ * 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.spark.procedure;
+
+import org.apache.paimon.FileStore;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.spark.SparkTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.TableCommitImpl;
+import org.apache.paimon.tag.Tag;
+import org.apache.paimon.utils.Preconditions;
+import org.apache.paimon.utils.SnapshotManager;
+import org.apache.paimon.utils.StringUtils;
+import org.apache.paimon.utils.TagManager;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.UUID;
+import java.util.function.Function;
+
+import static org.apache.spark.sql.types.DataTypes.LongType;
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/**
+ * Rollback to a snapshot or tag as the latest snapshot, without dropping the 
snapshots and tags
+ * created after it (unlike {@link RollbackProcedure}). Mirrors Flink's {@code
+ * rollback_to_as_latest} procedure.
+ */
+public class RollbackToAsLatestProcedure extends BaseProcedure {
+
+    private static final String ROLLBACK_TO_AS_LATEST_TAG_PREFIX = 
"rollback-to-as-latest-";
+
+    private static final ProcedureParameter[] PARAMETERS =
+            new ProcedureParameter[] {
+                ProcedureParameter.required("table", StringType),
+                ProcedureParameter.optional("tag", StringType),
+                ProcedureParameter.optional("snapshot_id", LongType)
+            };
+
+    private static final StructType OUTPUT_TYPE =
+            new StructType(
+                    new StructField[] {
+                        new StructField(
+                                "previous_snapshot_id",
+                                DataTypes.LongType,
+                                false,
+                                Metadata.empty()),
+                        new StructField(
+                                "rolled_back_snapshot_id",
+                                DataTypes.LongType,
+                                false,
+                                Metadata.empty()),
+                        new StructField(
+                                "current_snapshot_id", DataTypes.LongType, 
false, Metadata.empty())
+                    });
+
+    protected RollbackToAsLatestProcedure(TableCatalog tableCatalog) {
+        super(tableCatalog);
+    }
+
+    @Override
+    public ProcedureParameter[] parameters() {
+        return PARAMETERS;
+    }
+
+    @Override
+    public StructType outputType() {
+        return OUTPUT_TYPE;
+    }
+
+    @Override
+    public InternalRow[] call(InternalRow args) {
+        Identifier tableIdent = toIdentifier(args.getString(0), 
PARAMETERS[0].name());
+        String tagName = args.isNullAt(1) ? null : args.getString(1);
+        Long snapshotId = args.isNullAt(2) ? null : args.getLong(2);
+
+        return modifyPaimonTableRefreshingCacheOnFailure(
+                tableIdent,
+                table -> {
+                    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.");
+
+                    TagManager tagManager = store.newTagManager();
+                    Tag targetTag;
+                    Snapshot targetSnapshot;
+                    if (hasTag) {
+                        targetTag = tagManager.getOrThrow(tagName);
+                        targetSnapshot = targetTag.trimToSnapshot();
+                    } else {
+                        targetTag = null;
+                        targetSnapshot = findSnapshot(store, tagManager, 
snapshotId);
+                    }
+
+                    String createdRollbackTag = null;
+                    boolean canDeleteCreatedTag = true;
+                    String commitUser = ROLLBACK_TO_AS_LATEST_TAG_PREFIX + 
UUID.randomUUID();
+                    try {
+                        if (!hasTag) {
+                            createdRollbackTag =
+                                    createRollbackToAsLatestTag(tagManager, 
targetSnapshot);
+                            targetTag = 
tagManager.getOrThrow(createdRollbackTag);
+                        }
+                        try (TableCommitImpl commit = 
fileStoreTable.newCommit(commitUser)) {
+                            // The core rollback reads latest again, so 
another writer may change
+                            // the rollback snapshot ID. An exception may also 
come after a
+                            // successful commit (for example, from a 
callback). Keep the
+                            // protection tag unless the commit returns false.
+                            canDeleteCreatedTag = false;
+                            boolean success = 
commit.rollbackToAsLatest(targetTag);
+                            canDeleteCreatedTag = !success;
+                            Preconditions.checkState(
+                                    success,
+                                    "Failed to roll back to snapshot %s as 
latest.",
+                                    targetSnapshot.id());
+                        }
+                    } catch (Exception e) {
+                        try {
+                            if (createdRollbackTag != null && 
canDeleteCreatedTag) {
+                                tagManager.deleteTag(
+                                        createdRollbackTag,
+                                        store.newTagDeletion(),
+                                        store.snapshotManager(),
+                                        Collections.emptyList());
+                            }
+                        } catch (Exception cleanupException) {
+                            e.addSuppressed(cleanupException);
+                        }
+                        throw new RuntimeException(
+                                String.format(
+                                        "Failed to roll back to snapshot %s as 
latest.",
+                                        targetSnapshot.id()),
+                                e);
+                    }
+
+                    InternalRow outputRow =
+                            newInternalRow(
+                                    latestSnapshot.id(),
+                                    targetSnapshot.id(),
+                                    
store.snapshotManager().latestSnapshotId());
+                    return new InternalRow[] {outputRow};
+                });
+    }
+
+    /**
+     * Like {@link #modifyPaimonTable} but also refreshes Spark's cached plans 
when {@code func}
+     * throws. {@code rollback_to_as_latest} can publish the rollback snapshot 
and then fail (for
+     * example a post-commit callback throws), so the table state is already 
durable; the shared
+     * success-only refresh would otherwise leave {@code CACHE TABLE} serving 
the pre-rollback data.
+     * The original failure is preserved; a refresh error is only added as 
suppressed.
+     */
+    private InternalRow[] modifyPaimonTableRefreshingCacheOnFailure(
+            Identifier ident, Function<org.apache.paimon.table.Table, 
InternalRow[]> func) {
+        SparkTable sparkTable = loadSparkTable(ident);
+        try {
+            InternalRow[] result = func.apply(sparkTable.getTable());
+            refreshSparkCache(ident, sparkTable);
+            return result;
+        } catch (RuntimeException e) {
+            try {
+                refreshSparkCache(ident, sparkTable);
+            } catch (RuntimeException refreshError) {
+                e.addSuppressed(refreshError);
+            }
+            throw e;
+        }
+    }
+
+    private String createRollbackToAsLatestTag(TagManager tagManager, Snapshot 
targetSnapshot) {
+        String tagName =
+                ROLLBACK_TO_AS_LATEST_TAG_PREFIX + targetSnapshot.id() + "-" + 
UUID.randomUUID();
+        tagManager.createTag(targetSnapshot, tagName, null, 
Collections.emptyList(), false);
+        return tagName;
+    }
+
+    private Snapshot findSnapshot(FileStore<?> store, TagManager tagManager, 
long snapshotId) {
+        SnapshotManager snapshotManager = store.snapshotManager();
+        if (snapshotManager.snapshotExists(snapshotId)) {
+            return snapshotManager.snapshot(snapshotId);
+        }
+
+        SortedMap<Snapshot, List<String>> tags = tagManager.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));
+    }
+
+    public static ProcedureBuilder builder() {
+        return new BaseProcedure.Builder<RollbackToAsLatestProcedure>() {
+            @Override
+            public RollbackToAsLatestProcedure doBuild() {
+                return new RollbackToAsLatestProcedure(tableCatalog());
+            }
+        };
+    }
+
+    @Override
+    public String description() {
+        return "RollbackToAsLatestProcedure";
+    }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RollbackToAsLatestProcedureTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RollbackToAsLatestProcedureTest.scala
new file mode 100644
index 0000000000..e8b01d611b
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RollbackToAsLatestProcedureTest.scala
@@ -0,0 +1,186 @@
+/*
+ * 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.spark.procedure
+
+import org.apache.paimon.data.{BinaryString, GenericRow}
+import org.apache.paimon.manifest.ManifestCommittable
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.table.{ExpireSnapshotsImpl, FileStoreTable}
+import org.apache.paimon.table.sink.CommitCallback
+
+import org.apache.spark.sql.Row
+import org.assertj.core.api.Assertions.assertThatThrownBy
+
+class RollbackToAsLatestProcedureTest extends PaimonSparkTestBase {
+
+  test("Paimon Procedure: rollback to a snapshot as the latest snapshot") {
+    spark.sql("CREATE TABLE T (id INT, name STRING)")
+    spark.sql("INSERT INTO T VALUES (1, 'a')")
+    spark.sql("INSERT INTO T VALUES (2, 'b')")
+    spark.sql("INSERT INTO T VALUES (3, 'c')")
+
+    val snapshotManager = loadTable("T").snapshotManager
+
+    // Non-destructive: roll snapshot 1 forward as the new latest (snapshot 4);
+    // snapshots 2 and 3 stay, unlike the destructive `rollback`.
+    checkAnswer(
+      spark.sql("CALL paimon.sys.rollback_to_as_latest(table => 'test.T', 
snapshot_id => 1)"),
+      Row(3L, 1L, 4L) :: Nil)
+    assert(snapshotManager.snapshotExists(2))
+    assert(snapshotManager.snapshotExists(3))
+    checkAnswer(spark.sql("SELECT * FROM T"), Row(1, "a") :: Nil)
+
+    // Roll forward to snapshot 3 as the latest (snapshot 5).
+    checkAnswer(
+      spark.sql("CALL paimon.sys.rollback_to_as_latest(table => 'test.T', 
snapshot_id => 3)"),
+      Row(4L, 3L, 5L) :: Nil)
+    checkAnswer(spark.sql("SELECT * FROM T"), Row(1, "a") :: Row(2, "b") :: 
Row(3, "c") :: Nil)
+  }
+
+  test("Paimon Procedure: rollback keeps the protection tag after a 
post-commit failure") {
+    Seq(false, true).foreach {
+      concurrentCommit =>
+        withTable("T") {
+          createTableWithFailingCallback()
+          spark.sql("INSERT INTO T VALUES (1, 'original')")
+          // The restored file must not also belong to the snapshots retained 
before the rollback.
+          spark.sql("INSERT OVERWRITE T VALUES (2, 'replacement')")
+          spark.sql("INSERT INTO T VALUES (3, 'retained')")
+
+          FailingRollbackCallback.concurrentCommit = concurrentCommit
+          FailingRollbackCallback.failRollbackCommit = true
+          try {
+            assertThatThrownBy(() => rollbackToSnapshot1())
+              .hasStackTraceContaining("Injected post-commit callback failure")
+          } finally {
+            FailingRollbackCallback.reset()
+          }
+
+          val table = loadTable("T")
+          val latest = table.snapshotManager().latestSnapshot()
+          assert(latest.id() == (if (concurrentCommit) 5L else 4L))
+          assert(latest.commitUser().startsWith("rollback-to-as-latest-"))
+          val tags = table.tagManager().allTagNames()
+          assert(tags.size() == 1 && 
tags.get(0).startsWith("rollback-to-as-latest-1-"))
+          checkAnswer(spark.sql("SELECT * FROM T"), Row(1, "original") :: Nil)
+          val expire = 
table.newExpireSnapshots().asInstanceOf[ExpireSnapshotsImpl]
+          expire.expireUntil(1, latest.id())
+          checkAnswer(spark.sql("SELECT * FROM T"), Row(1, "original") :: Nil)
+        }
+    }
+  }
+
+  test("Paimon Procedure: rollback removes the protection tag when it never 
started") {
+    createTableWithFailingCallback()
+    spark.sql("INSERT INTO T VALUES (1, 'original')")
+
+    FailingRollbackCallback.failCommitCreation = true
+    try {
+      assertThatThrownBy(() => rollbackToSnapshot1())
+        .hasStackTraceContaining("Injected commit creation failure")
+    } finally {
+      FailingRollbackCallback.reset()
+    }
+
+    val table = loadTable("T")
+    assert(table.tagManager().allTagNames().isEmpty)
+    assert(table.snapshotManager().latestSnapshotId() == 1L)
+  }
+
+  test("Paimon Procedure: rollback refreshes a cached table after a 
post-commit failure") {
+    createTableWithFailingCallback()
+    spark.sql("INSERT INTO T VALUES (1, 'original')")
+    spark.sql("INSERT OVERWRITE T VALUES (2, 'replacement')")
+    spark.sql("CACHE TABLE T")
+    checkAnswer(spark.sql("SELECT * FROM T"), Row(2, "replacement") :: Nil)
+
+    FailingRollbackCallback.failRollbackCommit = true
+    try {
+      assertThatThrownBy(() => rollbackToSnapshot1())
+        .hasStackTraceContaining("Injected post-commit callback failure")
+    } finally {
+      FailingRollbackCallback.reset()
+    }
+
+    // Snapshot 3 (the rollback) is durable despite the callback failure, so 
the cached read must
+    // reflect snapshot 1's data rather than the stale pre-rollback 
replacement row.
+    checkAnswer(spark.sql("SELECT * FROM T"), Row(1, "original") :: Nil)
+  }
+
+  private def createTableWithFailingCallback(): Unit = {
+    val callback = classOf[FailingRollbackCallback].getName
+    spark.sql(
+      s"CREATE TABLE T (id INT, name STRING) TBLPROPERTIES ('commit.callbacks' 
= '$callback')")
+  }
+
+  private def rollbackToSnapshot1(): Unit = {
+    spark
+      .sql("CALL paimon.sys.rollback_to_as_latest(table => 'test.T', 
snapshot_id => 1)")
+      .collect()
+  }
+}
+
+/** Injects rollback failures, optionally after another writer commits a 
snapshot first. */
+class FailingRollbackCallback extends CommitCallback {
+
+  override def setTable(table: FileStoreTable): Unit = {
+    if (FailingRollbackCallback.failCommitCreation) {
+      throw new RuntimeException("Injected commit creation failure")
+    }
+    if (FailingRollbackCallback.concurrentCommit) {
+      // The procedure already read latest; the core rollback has not read it 
yet.
+      FailingRollbackCallback.concurrentCommit = false
+      val builder = table.newBatchWriteBuilder()
+      val write = builder.newWrite()
+      val commit = builder.newCommit()
+      try {
+        write.write(GenericRow.of(Int.box(4), 
BinaryString.fromString("concurrent")))
+        commit.commit(write.prepareCommit())
+      } finally {
+        write.close()
+        commit.close()
+      }
+    }
+  }
+
+  override def call(context: CommitCallback.Context): Unit = {
+    if (
+      FailingRollbackCallback.failRollbackCommit &&
+      context.snapshot.commitUser().startsWith("rollback-to-as-latest-")
+    ) {
+      throw new RuntimeException("Injected post-commit callback failure")
+    }
+  }
+
+  override def retry(committable: ManifestCommittable): Unit = {}
+
+  override def close(): Unit = {}
+}
+
+object FailingRollbackCallback {
+  @volatile var failRollbackCommit = false
+  @volatile var concurrentCommit = false
+  @volatile var failCommitCreation = false
+
+  def reset(): Unit = {
+    failRollbackCommit = false
+    concurrentCommit = false
+    failCommitCreation = false
+  }
+}

Reply via email to