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 d0af6e841f [flink] Support savepoint auto-tag on coordinator commit 
path (#9309)
d0af6e841f is described below

commit d0af6e841fd7168eca09b01b62572471e057be07
Author: Biao Liu <[email protected]>
AuthorDate: Sun Aug 23 23:28:55 2026 +0800

    [flink] Support savepoint auto-tag on coordinator commit path (#9309)
---
 .../sink/AutoTagForSavepointCommitterOperator.java |  14 +-
 ...dinatorCommittingRowDataStoreWriteOperator.java |  68 +-
 .../org/apache/paimon/flink/sink/FlinkSink.java    |   8 -
 .../paimon/flink/sink/RowAppendTableSink.java      |  62 +-
 .../paimon/flink/sink/SavepointTagUtils.java       |  67 ++
 .../sink/coordinator/CheckpointCommittables.java   |  32 +-
 .../CheckpointCommittablesSerializer.java          |  13 +-
 .../CommittingWriteOperatorCoordinator.java        |  68 +-
 .../flink/sink/coordinator/SavepointTagger.java    | 113 ++++
 .../AppendTableSavepointTagFailoverITCase.java     | 698 +++++++++++++++++++++
 .../flink/AppendTableSavepointTagITCase.java       | 248 ++++++++
 .../AutoTagForSavepointCommitterOperatorTest.java  |  41 +-
 .../paimon/flink/sink/CommitterOperatorTest.java   |   2 +-
 ...peratorTestBase.java => CommitterTestBase.java} |   6 +-
 ...torCommittingRowDataStoreWriteOperatorTest.java | 134 +++-
 .../apache/paimon/flink/sink/FlinkSinkTest.java    |  11 +-
 .../paimon/flink/sink/SavepointTagUtilsTest.java   | 132 ++++
 .../CheckpointCommittablesSerializerTest.java      |  37 +-
 .../CommittingWriteOperatorCoordinatorTest.java    | 173 ++++-
 .../sink/coordinator/SavepointTaggerTest.java      | 243 +++++++
 20 files changed, 2104 insertions(+), 66 deletions(-)

diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperator.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperator.java
index 66d9781207..48f8d65e2e 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperator.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperator.java
@@ -54,7 +54,6 @@ import java.util.TreeSet;
  */
 public class AutoTagForSavepointCommitterOperator<CommitT, GlobalCommitT>
         implements OneInputStreamOperator<CommitT, CommitT>, BoundedOneInput {
-    public static final String SAVEPOINT_TAG_PREFIX = "savepoint-";
 
     private static final long serialVersionUID = 1L;
 
@@ -152,10 +151,13 @@ public class 
AutoTagForSavepointCommitterOperator<CommitT, GlobalCommitT>
     public void notifyCheckpointAborted(long checkpointId) throws Exception {
         commitOperator.notifyCheckpointAborted(checkpointId);
         identifiersForTags.remove(checkpointId);
-        String tagName = SAVEPOINT_TAG_PREFIX + checkpointId;
-        if (tagManager.tagExists(tagName)) {
-            tagManager.deleteTag(tagName, tagDeletion, snapshotManager, 
callbacks);
-        }
+        SavepointTagUtils.deleteTagIfMatches(
+                tagManager,
+                commitOperator.getCommitUser(),
+                checkpointId,
+                tagDeletion,
+                snapshotManager,
+                callbacks);
     }
 
     private void createTagForIdentifiers(List<Long> identifiers) {
@@ -163,7 +165,7 @@ public class AutoTagForSavepointCommitterOperator<CommitT, 
GlobalCommitT>
                 snapshotManager.findSnapshotsForIdentifiers(
                         commitOperator.getCommitUser(), identifiers);
         for (Snapshot snapshot : snapshotForTags) {
-            String tagName = SAVEPOINT_TAG_PREFIX + 
snapshot.commitIdentifier();
+            String tagName = 
SavepointTagUtils.tagNameOf(snapshot.commitIdentifier());
             // shouldn't throw exception when tag exists
             tagManager.createTag(snapshot, tagName, tagTimeRetained, 
callbacks, true);
         }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
index 3bd617b5eb..7fcb87d15b 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
@@ -33,9 +33,11 @@ import org.apache.flink.api.common.state.ListStateDescriptor;
 import org.apache.flink.api.common.typeutils.TypeSerializer;
 import 
org.apache.flink.api.common.typeutils.base.array.BytePrimitiveArraySerializer;
 import org.apache.flink.core.io.SimpleVersionedSerializerTypeSerializerProxy;
+import org.apache.flink.runtime.checkpoint.CheckpointOptions;
 import org.apache.flink.runtime.operators.coordination.OperatorEventGateway;
+import org.apache.flink.runtime.state.CheckpointStreamFactory;
 import org.apache.flink.runtime.state.StateInitializationContext;
-import org.apache.flink.runtime.state.StateSnapshotContext;
+import org.apache.flink.streaming.api.operators.OperatorSnapshotFutures;
 import org.apache.flink.streaming.api.operators.StreamOperatorParameters;
 import org.apache.flink.streaming.api.operators.util.SimpleVersionedListState;
 import org.apache.flink.streaming.api.watermark.Watermark;
@@ -71,6 +73,9 @@ public class CoordinatorCommittingRowDataStoreWriteOperator
 
     private final OperatorEventGateway operatorEventGateway;
 
+    /** Whether savepoint auto-tagging is enabled; when off the writer never 
flags a tag intent. */
+    private final boolean autoTagForSavepoint;
+
     /** Persisted buffer of pending checkpoints not yet acknowledged by the 
coordinator. */
     private transient ListState<CheckpointCommittables> 
pendingCommittableState;
 
@@ -97,9 +102,11 @@ public class CoordinatorCommittingRowDataStoreWriteOperator
             FileStoreTable table,
             StoreSinkWrite.Provider storeSinkWriteProvider,
             String initialCommitUser,
-            OperatorEventGateway operatorEventGateway) {
+            OperatorEventGateway operatorEventGateway,
+            boolean autoTagForSavepoint) {
         super(parameters, table, storeSinkWriteProvider, initialCommitUser);
         this.operatorEventGateway = 
Preconditions.checkNotNull(operatorEventGateway);
+        this.autoTagForSavepoint = autoTagForSavepoint;
     }
 
     @Override
@@ -152,10 +159,26 @@ public class 
CoordinatorCommittingRowDataStoreWriteOperator
     }
 
     @Override
-    public void snapshotState(StateSnapshotContext context) throws Exception {
-        super.snapshotState(context);
+    public OperatorSnapshotFutures snapshotState(
+            long checkpointId,
+            long timestamp,
+            CheckpointOptions checkpointOptions,
+            CheckpointStreamFactory storageLocation)
+            throws Exception {
+        // Ordering within a checkpoint: emitCommittables already ran (in 
prepareSnapshotPreBarrier,
+        // before the barrier) and buffered this checkpoint's committables 
into pendingCommittables.
+        if (autoTagForSavepoint && 
checkpointOptions.getCheckpointType().isSavepoint()) {
+            pendingCommittables.computeIfPresent(
+                    checkpointId,
+                    (id, checkpointCommittables) ->
+                            
checkpointCommittables.withShouldCreateSavepointTag(true));
+        }
+        // Report here, not in emitCommittables, so the savepoint-tag intent 
is known before
+        // sending.
+        reportToCoordinator(checkpointId);
         pendingCommittableState.clear();
         pendingCommittableState.addAll(new 
ArrayList<>(pendingCommittables.values()));
+        return super.snapshotState(checkpointId, timestamp, checkpointOptions, 
storageLocation);
     }
 
     @Override
@@ -166,15 +189,27 @@ public class 
CoordinatorCommittingRowDataStoreWriteOperator
         pendingCommittables.headMap(checkpointId, true).clear();
     }
 
+    @Override
+    public void notifyCheckpointAborted(long checkpointId) throws Exception {
+        super.notifyCheckpointAborted(checkpointId);
+        if (!autoTagForSavepoint) {
+            return;
+        }
+        // Drop only the savepoint-tag intent on the aborted committables 
(keep the data). A later
+        // checkpoint must not persist a stale intent, or the coordinator 
would recreate a tag for a
+        // gone savepoint. Mirrors the operator path pruning the aborted id.
+        pendingCommittables.computeIfPresent(
+                checkpointId,
+                (id, checkpointCommittables) ->
+                        
checkpointCommittables.withShouldCreateSavepointTag(false));
+    }
+
     @Override
     protected void emitCommittables(boolean waitCompaction, long checkpointId) 
throws IOException {
         List<Committable> committables = prepareCommit(waitCompaction, 
checkpointId);
         CheckpointCommittables entry =
                 new CheckpointCommittables(
                         checkpointId, committables, currentWatermark, 
currentIdle);
-        // Emit an event per (subtask, checkpoint) regardless of whether 
committables is empty.
-        operatorEventGateway.sendEventToCoordinator(
-                CommittableEvent.create(checkpointId, entry, eventSerializer));
         // Always buffer the per-checkpoint entry so an empty barrier — even 
one that has not seen
         // a real watermark yet — survives restore. The coordinator relies on 
every subtask
         // having an entry for the checkpoint being aligned so its watermark 
min stays sound.
@@ -185,6 +220,25 @@ public class CoordinatorCommittingRowDataStoreWriteOperator
         committables.forEach(committable -> output.collect(new 
StreamRecord<>(committable)));
     }
 
+    @Override
+    public void endInput() throws Exception {
+        super.endInput();
+        // endInput emits the Long.MAX_VALUE committables but is not followed 
by a snapshotState,
+        // so report them here just to keep the existing behavior.
+        // TODO: revisit how end-of-input committables should be handled.
+        reportToCoordinator(Long.MAX_VALUE);
+    }
+
+    /**
+     * Sends the buffered committables for {@code checkpointId} to the 
coordinator, one per
+     * checkpoint.
+     */
+    private void reportToCoordinator(long checkpointId) throws IOException {
+        operatorEventGateway.sendEventToCoordinator(
+                CommittableEvent.create(
+                        checkpointId, pendingCommittables.get(checkpointId), 
eventSerializer));
+    }
+
     @Override
     public void processWatermark(Watermark mark) throws Exception {
         super.processWatermark(mark);
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 5d00584606..1445061bfc 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
@@ -410,14 +410,6 @@ public abstract class FlinkSink<T> implements Serializable 
{
                         + PRECOMMIT_COMPACT.key()
                         + " = false.");
 
-        // The OperatorCoordinator cannot tell a savepoint from a normal 
checkpoint.
-        // TODO support savepoint auto-tag.
-        checkArgument(
-                !options.get(SINK_AUTO_TAG_FOR_SAVEPOINT),
-                "Could not enable coordinator commit because "
-                        + SINK_AUTO_TAG_FOR_SAVEPOINT.key()
-                        + " is enabled, which is not supported yet.");
-
         // TODO concurrent checkpoints are not supported yet.
         checkArgument(
                 checkpointConfig.getMaxConcurrentCheckpoints() == 1,
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/RowAppendTableSink.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/RowAppendTableSink.java
index 443c9b6769..2e6b54fef1 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/RowAppendTableSink.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/RowAppendTableSink.java
@@ -21,9 +21,15 @@ package org.apache.paimon.flink.sink;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.flink.FlinkConnectorOptions;
 import 
org.apache.paimon.flink.sink.coordinator.CommittingWriteOperatorCoordinator;
+import org.apache.paimon.flink.sink.coordinator.SavepointTagger;
 import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.operation.TagDeletion;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.TagCallback;
+import org.apache.paimon.utils.SerializableSupplier;
+import org.apache.paimon.utils.SnapshotManager;
+import org.apache.paimon.utils.TagManager;
 
 import org.apache.flink.runtime.jobgraph.OperatorID;
 import org.apache.flink.runtime.operators.coordination.OperatorCoordinator;
@@ -33,6 +39,8 @@ import 
org.apache.flink.streaming.api.operators.OneInputStreamOperatorFactory;
 import org.apache.flink.streaming.api.operators.StreamOperator;
 import org.apache.flink.streaming.api.operators.StreamOperatorParameters;
 
+import java.time.Duration;
+import java.util.List;
 import java.util.Map;
 
 /** An {@link AppendTableSink} which handles {@link InternalRow}. */
@@ -56,7 +64,9 @@ public class RowAppendTableSink extends 
AppendTableSink<InternalRow> {
                     // checkpointing on by default for the JM-side committer; 
bounded sources will
                     // be handled by end-input support in a follow-up PR
                     true,
-                    createCommitterFactory());
+                    createCommitterFactory(),
+                    new Options(table.options())
+                            
.get(FlinkConnectorOptions.SINK_AUTO_TAG_FOR_SAVEPOINT));
         }
         return createNoStateRowWriteOperatorFactory(table, writeProvider, 
commitUser);
     }
@@ -82,9 +92,15 @@ public class RowAppendTableSink extends 
AppendTableSink<InternalRow> {
                     StoreSinkWrite.Provider writeProvider,
                     String commitUser,
                     boolean streamingCheckpointEnabled,
-                    Committer.Factory<Committable, ManifestCommittable> 
committerFactory) {
+                    Committer.Factory<Committable, ManifestCommittable> 
committerFactory,
+                    boolean autoTagForSavepoint) {
         return new CoordinatorCommittingFactory(
-                table, writeProvider, commitUser, streamingCheckpointEnabled, 
committerFactory);
+                table,
+                writeProvider,
+                commitUser,
+                streamingCheckpointEnabled,
+                committerFactory,
+                autoTagForSavepoint);
     }
 
     private static class CoordinatorCommittingFactory extends 
RowDataStoreWriteOperator.Factory
@@ -94,23 +110,52 @@ public class RowAppendTableSink extends 
AppendTableSink<InternalRow> {
 
         private final boolean streamingCheckpointEnabled;
         private final Committer.Factory<Committable, ManifestCommittable> 
committerFactory;
+        private final boolean autoTagForSavepoint;
 
         CoordinatorCommittingFactory(
                 FileStoreTable table,
                 StoreSinkWrite.Provider storeSinkWriteProvider,
                 String initialCommitUser,
                 boolean streamingCheckpointEnabled,
-                Committer.Factory<Committable, ManifestCommittable> 
committerFactory) {
+                Committer.Factory<Committable, ManifestCommittable> 
committerFactory,
+                boolean autoTagForSavepoint) {
             super(table, storeSinkWriteProvider, initialCommitUser);
             this.streamingCheckpointEnabled = streamingCheckpointEnabled;
             this.committerFactory = committerFactory;
+            this.autoTagForSavepoint = autoTagForSavepoint;
         }
 
         @Override
         public OperatorCoordinator.Provider getCoordinatorProvider(
                 String operatorName, OperatorID operatorID) {
             return new CommittingWriteOperatorCoordinator.Provider(
-                    operatorID, committerFactory, streamingCheckpointEnabled, 
initialCommitUser);
+                    operatorID,
+                    committerFactory,
+                    streamingCheckpointEnabled,
+                    initialCommitUser,
+                    autoTagForSavepoint ? createSavepointTaggerFactory(table) 
: null);
+        }
+
+        /**
+         * Builds the savepoint auto-tag factory. The factory captures only 
serializable suppliers
+         * and binds the commit user late, on the JM, once the coordinator has 
restored it.
+         */
+        private static SavepointTagger.Factory 
createSavepointTaggerFactory(FileStoreTable table) {
+            SerializableSupplier<SnapshotManager> snapshotManagerFactory = 
table::snapshotManager;
+            SerializableSupplier<TagManager> tagManagerFactory = 
table::tagManager;
+            SerializableSupplier<TagDeletion> tagDeletionFactory =
+                    () -> table.store().newTagDeletion();
+            SerializableSupplier<List<TagCallback>> callbacksSupplier =
+                    () -> table.store().createTagCallbacks(table);
+            Duration tagTimeRetained = 
table.coreOptions().tagDefaultTimeRetained();
+            return commitUser ->
+                    new SavepointTagger(
+                            snapshotManagerFactory.get(),
+                            tagManagerFactory.get(),
+                            tagDeletionFactory.get(),
+                            callbacksSupplier.get(),
+                            tagTimeRetained,
+                            commitUser);
         }
 
         @Override
@@ -122,7 +167,12 @@ public class RowAppendTableSink extends 
AppendTableSink<InternalRow> {
                     
parameters.getOperatorEventDispatcher().getOperatorEventGateway(operatorId);
             return (T)
                     new CoordinatorCommittingRowDataStoreWriteOperator(
-                            parameters, table, storeSinkWriteProvider, 
initialCommitUser, gateway);
+                            parameters,
+                            table,
+                            storeSinkWriteProvider,
+                            initialCommitUser,
+                            gateway,
+                            autoTagForSavepoint);
         }
 
         @Override
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SavepointTagUtils.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SavepointTagUtils.java
new file mode 100644
index 0000000000..b530cd864c
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/SavepointTagUtils.java
@@ -0,0 +1,67 @@
+/*
+ * 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.operation.TagDeletion;
+import org.apache.paimon.table.sink.TagCallback;
+import org.apache.paimon.tag.Tag;
+import org.apache.paimon.utils.SnapshotManager;
+import org.apache.paimon.utils.TagManager;
+
+import java.util.List;
+
+/** Helpers for savepoint tags — the tags Paimon auto-creates to mark Flink 
savepoints. */
+public class SavepointTagUtils {
+
+    /** Prefix shared by every savepoint auto-tag; use {@link 
#tagNameOf(long)} for a full name. */
+    public static final String PREFIX = "savepoint-";
+
+    /** Name of the auto-tag for a savepoint committed under {@code 
commitIdentifier}. */
+    public static String tagNameOf(long commitIdentifier) {
+        return PREFIX + commitIdentifier;
+    }
+
+    /**
+     * Whether the tag belongs to the savepoint committed by the given user 
and identifier.
+     *
+     * <p>Checkpoint identifiers restart with each job, so a matching tag name 
alone does not prove
+     * ownership.
+     */
+    public static boolean isSavepointTagFor(Tag tag, String commitUser, long 
commitIdentifier) {
+        return commitUser.equals(tag.commitUser()) && tag.commitIdentifier() 
== commitIdentifier;
+    }
+
+    /** Deletes the savepoint tag if it belongs to the given commit user and 
identifier. */
+    public static void deleteTagIfMatches(
+            TagManager tagManager,
+            String commitUser,
+            long commitIdentifier,
+            TagDeletion tagDeletion,
+            SnapshotManager snapshotManager,
+            List<TagCallback> callbacks) {
+        String tagName = tagNameOf(commitIdentifier);
+        tagManager
+                .get(tagName)
+                .filter(tag -> isSavepointTagFor(tag, commitUser, 
commitIdentifier))
+                .ifPresent(
+                        ignored ->
+                                tagManager.deleteTag(
+                                        tagName, tagDeletion, snapshotManager, 
callbacks));
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
index 3e08c1f77f..98cdd18291 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
@@ -35,19 +35,33 @@ public class CheckpointCommittables {
     // Idle bit is frozen at barrier time together with watermark; mirrors 
what Flink's
     // StatusWatermarkValve would have observed on the writer's input at the 
moment of the barrier.
     private final boolean idle;
+    // Whether a savepoint tag should be created for the checkpoint that 
produced these
+    // committables.
+    private final boolean shouldCreateSavepointTag;
 
     public CheckpointCommittables(
-            long checkpointId, List<Committable> committables, long watermark, 
boolean idle) {
+            long checkpointId,
+            List<Committable> committables,
+            long watermark,
+            boolean idle,
+            boolean shouldCreateSavepointTag) {
         this.checkpointId = checkpointId;
         this.committables = committables;
         this.watermark = watermark;
         this.idle = idle;
+        this.shouldCreateSavepointTag = shouldCreateSavepointTag;
+    }
+
+    // Convenience for callers that are not savepoint-aware yet.
+    public CheckpointCommittables(
+            long checkpointId, List<Committable> committables, long watermark, 
boolean idle) {
+        this(checkpointId, committables, watermark, idle, false);
     }
 
     // Convenience for callers that only need the pre-idle-aware shape (ACTIVE 
writer).
     public CheckpointCommittables(
             long checkpointId, List<Committable> committables, long watermark) 
{
-        this(checkpointId, committables, watermark, false);
+        this(checkpointId, committables, watermark, false, false);
     }
 
     public long checkpointId() {
@@ -66,6 +80,16 @@ public class CheckpointCommittables {
         return idle;
     }
 
+    public boolean shouldCreateSavepointTag() {
+        return shouldCreateSavepointTag;
+    }
+
+    /** Returns a copy with the savepoint-tag intent set; all other fields 
preserved. */
+    public CheckpointCommittables withShouldCreateSavepointTag(boolean 
shouldCreateSavepointTag) {
+        return new CheckpointCommittables(
+                checkpointId, committables, watermark, idle, 
shouldCreateSavepointTag);
+    }
+
     public int size() {
         return committables.size();
     }
@@ -77,7 +101,7 @@ public class CheckpointCommittables {
     @Override
     public String toString() {
         return String.format(
-                "CheckpointCommittables{checkpointId=%d, watermark=%d, 
idle=%s, committables=%s}",
-                checkpointId, watermark, idle, committables);
+                "CheckpointCommittables{checkpointId=%d, watermark=%d, 
idle=%s, shouldCreateSavepointTag=%s, committables=%s}",
+                checkpointId, watermark, idle, shouldCreateSavepointTag, 
committables);
     }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
index b557559996..5e51385c80 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
@@ -43,7 +43,8 @@ public class CheckpointCommittablesSerializer
     public int getVersion() {
         // v1: checkpointId + watermark + committables
         // v2: v1 + idle bit (appended before the committable list to keep the 
ordering explicit)
-        return 2;
+        // v3: v2 + savepoint-tag bit (appended after idle, still before the 
committable list)
+        return 3;
     }
 
     @Override
@@ -52,6 +53,7 @@ public class CheckpointCommittablesSerializer
         out.writeLong(value.checkpointId());
         out.writeLong(value.watermark());
         out.writeBoolean(value.idle());
+        out.writeBoolean(value.shouldCreateSavepointTag());
         // Nested serializer version comes before the list so the reader can 
pick the right decoder
         // before touching any list bytes — mirrors 
ManifestCommittableSerializer's layout.
         out.writeInt(committableSerializer.getVersion());
@@ -67,7 +69,7 @@ public class CheckpointCommittablesSerializer
 
     @Override
     public CheckpointCommittables deserialize(int version, byte[] serialized) 
throws IOException {
-        if (version != 1 && version != 2) {
+        if (version != 1 && version != 2 && version != 3) {
             throw new IOException("Unknown version " + version);
         }
         DataInputDeserializer in = new DataInputDeserializer(serialized);
@@ -75,7 +77,9 @@ public class CheckpointCommittablesSerializer
         long watermark = in.readLong();
         // v1 payloads pre-date idle tracking; default to ACTIVE (idle=false), 
which reproduces
         // the pre-idle-aware behaviour: every subtask contributes to the min 
unconditionally.
-        boolean idle = version >= 2 && in.readBoolean();
+        boolean idle = version >= 2 ? in.readBoolean() : false;
+        // v1/v2 payloads pre-date the savepoint-tag bit; older payloads have 
no tag.
+        boolean shouldCreateSavepointTag = version >= 3 ? in.readBoolean() : 
false;
         int committableVersion = in.readInt();
         int count = in.readInt();
         List<Committable> committables = new ArrayList<>(count);
@@ -85,6 +89,7 @@ public class CheckpointCommittablesSerializer
             in.readFully(bytes);
             
committables.add(committableSerializer.deserialize(committableVersion, bytes));
         }
-        return new CheckpointCommittables(checkpointId, committables, 
watermark, idle);
+        return new CheckpointCommittables(
+                checkpointId, committables, watermark, idle, 
shouldCreateSavepointTag);
     }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
index 3ef4b26d42..e792432a31 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
@@ -39,6 +39,8 @@ import org.apache.flink.util.function.ThrowingRunnable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
+
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -77,6 +79,7 @@ public class CommittingWriteOperatorCoordinator implements 
OperatorCoordinator {
     private final Committer.Factory<Committable, ManifestCommittable> 
committerFactory;
     private final boolean streamingCheckpointEnabled;
     private final int parallelism;
+    @Nullable private final SavepointTagger.Factory savepointTaggerFactory;
 
     private final WriterCommittables[] subtaskCommittables;
     private final TypeSerializer<CheckpointCommittables> 
committablesSerializer;
@@ -95,16 +98,20 @@ public class CommittingWriteOperatorCoordinator implements 
OperatorCoordinator {
     private Committer<Committable, ManifestCommittable> committer;
     private String commitUser;
     private MemoryBackendStateStore stateStore;
+    // Built in initializeAfterRestore once commitUser is known; null when 
auto-tag is disabled.
+    @Nullable private SavepointTagger savepointTagger;
 
     public CommittingWriteOperatorCoordinator(
             OperatorCoordinator.Context context,
             Committer.Factory<Committable, ManifestCommittable> 
committerFactory,
             boolean streamingCheckpointEnabled,
-            String initialCommitUser) {
+            String initialCommitUser,
+            @Nullable SavepointTagger.Factory savepointTaggerFactory) {
         this.context = context;
         this.committerFactory = committerFactory;
         this.streamingCheckpointEnabled = streamingCheckpointEnabled;
         this.commitUser = initialCommitUser;
+        this.savepointTaggerFactory = savepointTaggerFactory;
         this.parallelism = context.currentParallelism();
         this.subtaskCommittables = new WriterCommittables[parallelism];
         this.committablesSerializer =
@@ -134,11 +141,11 @@ public class CommittingWriteOperatorCoordinator 
implements OperatorCoordinator {
                         restoreState(restoredCheckpointId, 
restoredCheckpointData);
                         // not needed after deserialization; release the 
reference
                         restoredCheckpointData = null;
-                        initializeCommitter(true);
+                        initializeAfterRestore(true);
                         // stay in RESTORING until writers re-emit 
committables and align catches up
                     } else {
                         restoreState(OperatorCoordinator.NO_CHECKPOINT, null);
-                        initializeCommitter(false);
+                        initializeAfterRestore(false);
                         transitionState(State.RUNNING);
                     }
                 },
@@ -233,11 +240,33 @@ public class CommittingWriteOperatorCoordinator 
implements OperatorCoordinator {
                                     throw new RuntimeException(e);
                                 }
                             });
+                    // An async savepoint does not fire 
notifyCheckpointComplete for its own id
+                    // (FLIP-193), so its tag cannot be created when the 
savepoint completes.
+                    // Catch up on each checkpoint completion instead, tagging 
every pending
+                    // savepoint id up to checkpointId once the commit 
materialized its snapshot.
+                    if (savepointTagger != null) {
+                        savepointTagger.tagUpTo(checkpointId);
+                    }
                 },
                 "completing checkpoint %d",
                 checkpointId);
     }
 
+    @Override
+    public void notifyCheckpointAborted(long checkpointId) {
+        // Runs tag I/O on the commit executor, never the JM main thread. An 
aborted savepoint may
+        // already have been tagged by a later checkpoint's completion 
(cumulative commit), so drop
+        // the pending intent and remove any tag that was created.
+        runInEventLoop(
+                () -> {
+                    if (savepointTagger != null) {
+                        savepointTagger.dropAborted(checkpointId);
+                    }
+                },
+                "aborting checkpoint %d",
+                checkpointId);
+    }
+
     /**
      * Called by the framework at most once, before {@link #start()}. May be 
skipped entirely if the
      * job has no checkpoint or savepoint to restore from; in that case the 
coordinator goes
@@ -333,6 +362,16 @@ public class CommittingWriteOperatorCoordinator implements 
OperatorCoordinator {
     }
 
     private void updateSubtaskCommittables(int subtask, WriterCommittables 
incoming) {
+        if (savepointTagger != null) {
+            // Collect savepoint intents as events arrive (steady state and 
restore both funnel
+            // here), rebuilding the pending-tag set without checkpointing it.
+            for (CheckpointCommittables checkpointCommittables :
+                    incoming.getCommittablesPerCheckpoint().values()) {
+                if (checkpointCommittables.shouldCreateSavepointTag()) {
+                    savepointTagger.add(checkpointCommittables.checkpointId());
+                }
+            }
+        }
         if (subtaskCommittables[subtask] != null) {
             subtaskCommittables[subtask].mergeWith(incoming);
         } else {
@@ -360,6 +399,11 @@ public class CommittingWriteOperatorCoordinator implements 
OperatorCoordinator {
                         checkpointId, subtaskCommittables, 
watermarkPerCheckpoint, committer),
                 watermarkPerCheckpoint,
                 committables -> committer.filterAndCommit(committables, true, 
true));
+        // Tag any restored savepoint(s) whose snapshot the re-commit 
materialized, so a
+        // restore-from-savepoint still produces the savepoint tag.
+        if (savepointTagger != null) {
+            savepointTagger.tagUpTo(checkpointId);
+        }
     }
 
     @VisibleForTesting
@@ -471,7 +515,7 @@ public class CommittingWriteOperatorCoordinator implements 
OperatorCoordinator {
         }
     }
 
-    private void initializeCommitter(boolean isRestored) {
+    private void initializeAfterRestore(boolean isRestored) {
         // Coordinator runs at parallelism 1 (single instance per JobVertex), 
matching
         // CommitterOperator's contract; hardcode parallelism=1 / 
subtaskIndex=0
         Committer.Context committerContext =
@@ -484,6 +528,11 @@ public class CommittingWriteOperatorCoordinator implements 
OperatorCoordinator {
                         1,
                         0);
         committer = committerFactory.create(committerContext);
+        // Bind the tagger to the (possibly restored) commit user, so 
findSnapshotsForIdentifiers
+        // matches the snapshots this coordinator commits.
+        if (savepointTaggerFactory != null) {
+            savepointTagger = savepointTaggerFactory.create(commitUser);
+        }
     }
 
     private void transitionState(State targetState) {
@@ -600,22 +649,29 @@ public class CommittingWriteOperatorCoordinator 
implements OperatorCoordinator {
         private final Committer.Factory<Committable, ManifestCommittable> 
committerFactory;
         private final boolean streamingCheckpointEnabled;
         private final String initialCommitUser;
+        @Nullable private final SavepointTagger.Factory savepointTaggerFactory;
 
         public Provider(
                 OperatorID operatorId,
                 Committer.Factory<Committable, ManifestCommittable> 
committerFactory,
                 boolean streamingCheckpointEnabled,
-                String initialCommitUser) {
+                String initialCommitUser,
+                @Nullable SavepointTagger.Factory savepointTaggerFactory) {
             super(operatorId);
             this.committerFactory = committerFactory;
             this.streamingCheckpointEnabled = streamingCheckpointEnabled;
             this.initialCommitUser = initialCommitUser;
+            this.savepointTaggerFactory = savepointTaggerFactory;
         }
 
         @Override
         public OperatorCoordinator getCoordinator(OperatorCoordinator.Context 
context) {
             return new CommittingWriteOperatorCoordinator(
-                    context, committerFactory, streamingCheckpointEnabled, 
initialCommitUser);
+                    context,
+                    committerFactory,
+                    streamingCheckpointEnabled,
+                    initialCommitUser,
+                    savepointTaggerFactory);
         }
     }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/SavepointTagger.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/SavepointTagger.java
new file mode 100644
index 0000000000..e762f55e01
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/SavepointTagger.java
@@ -0,0 +1,113 @@
+/*
+ * 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.coordinator;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.flink.sink.SavepointTagUtils;
+import org.apache.paimon.operation.TagDeletion;
+import org.apache.paimon.table.sink.TagCallback;
+import org.apache.paimon.utils.SnapshotManager;
+import org.apache.paimon.utils.TagManager;
+
+import java.io.Serializable;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.NavigableSet;
+import java.util.TreeSet;
+
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/**
+ * Owns savepoint auto-tagging for {@link CommittingWriteOperatorCoordinator}, 
replicating the
+ * semantics of the classic {@link
+ * org.apache.paimon.flink.sink.AutoTagForSavepointCommitterOperator} for the 
coordinator-commit
+ * path. It keeps the set of savepoint checkpoint ids still awaiting a 
snapshot to tag; this set is
+ * deliberately not checkpointed but rebuilt from the savepoint ids replayed 
with each subtask's
+ * committables, so the coordinator's persisted state stays minimal.
+ */
+public class SavepointTagger {
+
+    private final SnapshotManager snapshotManager;
+    private final TagManager tagManager;
+    private final TagDeletion tagDeletion;
+    private final List<TagCallback> callbacks;
+    private final Duration tagTimeRetained;
+    // findSnapshotsForIdentifiers filters by commit user, so the tagger must 
be bound to the user
+    // the coordinator actually commits with (which the coordinator restores 
from its state).
+    private final String commitUser;
+    // Checkpoint ids of pending Flink savepoints awaiting a snapshot to tag.
+    private final NavigableSet<Long> pendingIdentifiers = new TreeSet<>();
+
+    public SavepointTagger(
+            SnapshotManager snapshotManager,
+            TagManager tagManager,
+            TagDeletion tagDeletion,
+            List<TagCallback> callbacks,
+            Duration tagTimeRetained,
+            String commitUser) {
+        this.snapshotManager = checkNotNull(snapshotManager);
+        this.tagManager = checkNotNull(tagManager);
+        this.tagDeletion = checkNotNull(tagDeletion);
+        this.callbacks = checkNotNull(callbacks);
+        this.tagTimeRetained = tagTimeRetained;
+        this.commitUser = checkNotNull(commitUser);
+    }
+
+    public void add(long savepointIdentifier) {
+        pendingIdentifiers.add(savepointIdentifier);
+    }
+
+    /**
+     * Tags every pending savepoint whose snapshot the commit up to {@code 
checkpointId} has
+     * materialized, then drops those pending intents.
+     */
+    public void tagUpTo(long checkpointId) {
+        NavigableSet<Long> headSet = pendingIdentifiers.headSet(checkpointId, 
true);
+        if (!headSet.isEmpty()) {
+            createTags(new ArrayList<>(headSet));
+            headSet.clear();
+        }
+    }
+
+    /** Drops an aborted savepoint's pending intent and removes any tag 
already created for it. */
+    public void dropAborted(long checkpointId) {
+        pendingIdentifiers.remove(checkpointId);
+        SavepointTagUtils.deleteTagIfMatches(
+                tagManager, commitUser, checkpointId, tagDeletion, 
snapshotManager, callbacks);
+    }
+
+    private void createTags(Collection<Long> identifiers) {
+        List<Snapshot> snapshots =
+                snapshotManager.findSnapshotsForIdentifiers(
+                        commitUser, new ArrayList<>(identifiers));
+        for (Snapshot snapshot : snapshots) {
+            String tagName = 
SavepointTagUtils.tagNameOf(snapshot.commitIdentifier());
+            // ignoreIfExists: a later checkpoint's completion may re-tag an 
already-tagged
+            // snapshot.
+            tagManager.createTag(snapshot, tagName, tagTimeRetained, 
callbacks, true);
+        }
+    }
+
+    /** Builds a {@link SavepointTagger} bound to the coordinator's restored 
commit user. */
+    public interface Factory extends Serializable {
+        SavepointTagger create(String commitUser);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/AppendTableSavepointTagFailoverITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/AppendTableSavepointTagFailoverITCase.java
new file mode 100644
index 0000000000..49198b4f51
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/AppendTableSavepointTagFailoverITCase.java
@@ -0,0 +1,698 @@
+/*
+ * 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;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.sink.FlinkSinkBuilder;
+import org.apache.paimon.flink.sink.SavepointTagUtils;
+import org.apache.paimon.flink.source.AbstractNonCoordinatedSource;
+import org.apache.paimon.flink.source.AbstractNonCoordinatedSourceReader;
+import org.apache.paimon.flink.source.SimpleSourceSplit;
+import org.apache.paimon.flink.util.AbstractTestBase;
+import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.CommitCallback;
+
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.ReaderOutput;
+import org.apache.flink.api.connector.source.SourceReader;
+import org.apache.flink.api.connector.source.SourceReaderContext;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.RestartStrategyOptions;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.core.execution.SavepointFormatType;
+import org.apache.flink.core.io.InputStatus;
+import org.apache.flink.runtime.checkpoint.CheckpointException;
+import org.apache.flink.runtime.checkpoint.CheckpointOptions;
+import org.apache.flink.runtime.jobgraph.SavepointRestoreSettings;
+import org.apache.flink.runtime.state.CheckpointStreamFactory;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.datastream.DataStreamSource;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
+import org.apache.flink.streaming.api.operators.OneInputStreamOperator;
+import org.apache.flink.streaming.api.operators.OperatorSnapshotFutures;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.table.api.EnvironmentSettings;
+import org.apache.flink.table.api.TableEnvironment;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.table.types.logical.VarCharType;
+import org.apache.flink.util.ExceptionUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Failover and restore behavior of savepoint auto-tag for unaware-bucket 
append tables, asserting
+ * that the coordinator-commit path stays at parity with the classic 
operator-commit path.
+ */
+public class AppendTableSavepointTagFailoverITCase extends AbstractTestBase {
+
+    private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
+
+    @BeforeEach
+    public void resetInjectors() {
+        // These injectors coordinate through static fields shared across 
every test in this JVM;
+        // reset them up front so no test inherits another's leftover 
arming/one-shot state.
+        FailOnSavepointOperator.reset();
+        BlockCheckpointAfterSavepointOperator.disarmBlocking();
+        FailOnFirstPostRecoveryCommitCallback.reset();
+    }
+
+    /** The tag already exists on restore; recover() must re-tag idempotently. 
*/
+    @ParameterizedTest(name = "coordinatorCommit = {0}")
+    @ValueSource(booleans = {true, false})
+    @Timeout(value = 180, unit = TimeUnit.SECONDS)
+    public void testSavepointTagIdempotentOnRestore(boolean coordinatorCommit) 
throws Exception {
+        String tableName = coordinatorCommit ? "T_COORD_IDEMPOTENT" : 
"T_CLASSIC_IDEMPOTENT";
+        FileStoreTable table = createTable(tableName, coordinatorCommit);
+
+        // Phase 1: run the job, take a savepoint, and let its tag 
materialize. Keep the tag in
+        // place so phase 2 restores into a state where the tag already exists.
+        String savepointPath;
+        long taggedIdentifier;
+        JobClient firstClient = runSink(table, null);
+        try {
+            waitUntilSnapshotWithData(table);
+            savepointPath =
+                    firstClient
+                            .triggerSavepoint(
+                                    getTempDirPath("savepoint_" + tableName),
+                                    SavepointFormatType.DEFAULT)
+                            .get(60, TimeUnit.SECONDS);
+            Map<Snapshot, List<String>> tags = 
waitUntilSavepointTagCreated(table);
+            assertThat(tags).hasSize(1);
+            taggedIdentifier = 
tags.keySet().iterator().next().commitIdentifier();
+        } finally {
+            firstClient.cancel().get(30, TimeUnit.SECONDS);
+        }
+
+        // Phase 2: restore from the savepoint while the tag is already 
present. recover() re-runs
+        // the tag creation; SavepointTagger.createTag with ignoreIfExists 
must make it a no-op, so
+        // the tag stays unique and unchanged.
+        JobClient secondClient = runSink(table, savepointPath);
+        try {
+            // A snapshot committed after restore proves recover() has already 
run (its re-tag
+            // happens before the resumed job commits again), so no fixed 
sleep is needed.
+            waitUntilRecoveredAndCommitting(table);
+            Map<Snapshot, List<String>> tags = savepointTags(table);
+            assertThat(tags).hasSize(1);
+            Map.Entry<Snapshot, List<String>> snapshotWithTags = 
tags.entrySet().iterator().next();
+            assertThat(snapshotWithTags.getValue())
+                    .containsExactly(
+                            SavepointTagUtils.tagNameOf(
+                                    
snapshotWithTags.getKey().commitIdentifier()));
+            
assertThat(snapshotWithTags.getKey().commitIdentifier()).isEqualTo(taggedIdentifier);
+        } finally {
+            secondClient.cancel().get(30, TimeUnit.SECONDS);
+        }
+    }
+
+    /**
+     * The tag was never created (no checkpoint completed after the 
savepoint); restore must create
+     * it.
+     */
+    @ParameterizedTest(name = "coordinatorCommit = {0}")
+    @ValueSource(booleans = {true, false})
+    @Timeout(value = 180, unit = TimeUnit.SECONDS)
+    public void testSavepointTagRecreatedOnRestore(boolean coordinatorCommit) 
throws Exception {
+        String tableName = coordinatorCommit ? "T_COORD_RECREATE" : 
"T_CLASSIC_RECREATE";
+        FileStoreTable table = createTable(tableName, coordinatorCommit);
+
+        // Phase 1: take a savepoint, and block every normal checkpoint that 
would follow it so none
+        // can materialize the tag before we cancel. The savepoint's snapshot 
is captured but its
+        // tag
+        // is never created yet.
+        BlockCheckpointAfterSavepointOperator.armBlocking();
+        String savepointPath;
+        JobClient firstClient = runSink(table, null, true);
+        try {
+            waitUntilSnapshotWithData(table);
+            savepointPath =
+                    firstClient
+                            .triggerSavepoint(
+                                    getTempDirPath("savepoint_" + tableName),
+                                    SavepointFormatType.DEFAULT)
+                            .get(60, TimeUnit.SECONDS);
+        } finally {
+            firstClient.cancel().get(30, TimeUnit.SECONDS);
+        }
+        assertThat(savepointTags(table)).isEmpty();
+
+        // Phase 2: restore from the savepoint with blocking disarmed (same 
topology so the writer
+        // state maps back). The writer replays the pending savepoint bit; the 
commit that follows
+        // materializes the savepoint's snapshot and its tag is created for 
the first time here (via
+        // recover() on coordinator, or committer ListState on the classic 
path).
+        BlockCheckpointAfterSavepointOperator.disarmBlocking();
+        JobClient secondClient = runSink(table, savepointPath, true);
+        try {
+            Map<Snapshot, List<String>> tags = 
waitUntilSavepointTagCreated(table);
+            assertThat(tags).hasSize(1);
+            Map.Entry<Snapshot, List<String>> snapshotWithTags = 
tags.entrySet().iterator().next();
+            assertThat(snapshotWithTags.getValue())
+                    .containsExactly(
+                            SavepointTagUtils.tagNameOf(
+                                    
snapshotWithTags.getKey().commitIdentifier()));
+        } finally {
+            secondClient.cancel().get(30, TimeUnit.SECONDS);
+        }
+    }
+
+    /**
+     * Region failover: a single writer subtask throws while a savepoint is in 
flight, so only that
+     * region restarts and the coordinator keeps running. The interrupted 
savepoint never commits
+     * its snapshot, so it produces no tag; a savepoint taken after recovery 
must be tagged. Region
+     * failover is a coordinator-commit-only concern (the classic committer 
shares a failover region
+     * with the writers), so this is not parameterized.
+     */
+    @Test
+    @Timeout(value = 180, unit = TimeUnit.SECONDS)
+    public void testRegionFailoverPreservesSavepointTag() throws Exception {
+        String tableName = "T_COORD_REGION_FAILOVER";
+        FileStoreTable table = createTable(tableName, true);
+
+        Configuration conf = new Configuration();
+        conf.set(RestartStrategyOptions.RESTART_STRATEGY, "fixed-delay");
+        conf.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_ATTEMPTS, 
Integer.MAX_VALUE);
+        conf.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_DELAY, 
Duration.ofSeconds(1));
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(conf);
+        env.setParallelism(2);
+        env.enableCheckpointing(200);
+        DataStreamSource<RowData> source =
+                env.fromSource(
+                        new ContinuousSource(), 
WatermarkStrategy.noWatermarks(), "region-source");
+        // forward + same parallelism so source, fail-injector, and writer are 
one failover region
+        // per subtask; a single subtask's failure restarts only its region, 
not the whole job.
+        DataStream<RowData> injected =
+                source.forward()
+                        .transform(
+                                "fail-on-savepoint",
+                                InternalTypeInfo.of(RowType.of(new IntType(), 
new VarCharType())),
+                                new FailOnSavepointOperator(1))
+                        .setParallelism(2);
+        new FlinkSinkBuilder(table).forRowData(injected).build();
+        JobClient client = env.executeAsync("region-failover-savepoint-tag");
+        try {
+            waitUntilSnapshotWithData(table);
+
+            // Savepoint 1: subtask 1 throws while snapshotting it, which 
aborts the in-flight
+            // savepoint and triggers a region failover. The trigger future 
must fail, and the
+            // failure must be the savepoint being interrupted by that 
failover — the checkpoint
+            // coordinator suspends — not some unrelated error. The injected 
exception is not on
+            // this
+            // chain: it fails the task (driving the failover) rather than 
propagating to the
+            // trigger
+            // future, which only sees the coordinator suspending, so 
CheckpointException is the
+            // tightest type we can assert here.
+            assertThatThrownBy(
+                            () ->
+                                    client.triggerSavepoint(
+                                                    
getTempDirPath("savepoint1_" + tableName),
+                                                    
SavepointFormatType.DEFAULT)
+                                            .get(60, TimeUnit.SECONDS))
+                    .satisfies(
+                            e ->
+                                    assertThat(
+                                                    
ExceptionUtils.findThrowable(
+                                                            e, 
CheckpointException.class))
+                                            .isPresent());
+            assertThat(savepointTags(table)).isEmpty();
+
+            // Wait until the job has recovered and resumed committing after 
the region failover.
+            waitUntilRecoveredAndCommitting(table);
+
+            // Savepoint 2: after recovery it must be tagged correctly, 
proving the region failover
+            // did not break the coordinator's auto-tag state.
+            client.triggerSavepoint(
+                            getTempDirPath("savepoint2_" + tableName), 
SavepointFormatType.DEFAULT)
+                    .get(60, TimeUnit.SECONDS);
+            Map<Snapshot, List<String>> tags = 
waitUntilSavepointTagCreated(table);
+            assertThat(tags).hasSize(1);
+            Map.Entry<Snapshot, List<String>> snapshotWithTags = 
tags.entrySet().iterator().next();
+            assertThat(snapshotWithTags.getValue())
+                    .containsExactly(
+                            SavepointTagUtils.tagNameOf(
+                                    
snapshotWithTags.getKey().commitIdentifier()));
+            
assertThat(table.snapshotManager().snapshotExists(snapshotWithTags.getKey().id()))
+                    .isTrue();
+        } finally {
+            client.cancel().get(30, TimeUnit.SECONDS);
+        }
+    }
+
+    /**
+     * An async savepoint is aborted (one writer failed while others 
succeeded), then a global
+     * failover restores from the first normal checkpoint after the abort. The 
surviving writer
+     * still carries the aborted savepoint's bit in that checkpoint's state, 
so the coordinator must
+     * NOT recreate the savepoint's tag on restore. This is the 
coordinator-vs-operator consistency
+     * gap: the operator path prunes the aborted id from its checkpointed set, 
while the coordinator
+     * rebuilds its pending-tag set from the writer-replayed bits.
+     *
+     * <p>Deterministic reproduction of "restore exactly from the one 
checkpoint that carries the
+     * stale bit":
+     *
+     * <ol>
+     *   <li>Parallelism 2. A savepoint S is taken; subtask 1 throws in its 
snapshot, aborting S and
+     *       forcing a region failover while the coordinator (and subtask 0) 
keep running. Subtask 0
+     *       keeps S's savepoint bit in its in-memory pending buffer (the 
writer has no
+     *       notifyCheckpointAborted hook to clear it).
+     *   <li>The job resumes and the first normal checkpoint C after recovery 
persists subtask 0's
+     *       buffer — including S's stale bit — into C's operator state.
+     *   <li>A commit callback throws exactly once, on the commit of that 
first post-recovery
+     *       checkpoint, to force a GLOBAL failover whose latest completed 
checkpoint is C.
+     *   <li>Global restore replays C's committables (with S's stale bit) to a 
fresh coordinator,
+     *       which re-collects S and, after recover() commits S's snapshot, 
would tag it. The tag is
+     *       an orphan: S was aborted and its Flink savepoint no longer exists.
+     * </ol>
+     *
+     * <p>The assertion is that no savepoint tag exists after the dust 
settles. This is
+     * coordinator-commit-only (region failover requires the coordinator 
path), so it is not
+     * parameterized.
+     */
+    @Test
+    @Timeout(value = 180, unit = TimeUnit.SECONDS)
+    public void testAbortedSavepointNotRetaggedAfterGlobalFailover() throws 
Exception {
+        String tableName = "T_COORD_ABORT_RETAG";
+        FileStoreTable table =
+                createTable(tableName, true, 
FailOnFirstPostRecoveryCommitCallback.class.getName());
+
+        Configuration conf = new Configuration();
+        conf.set(RestartStrategyOptions.RESTART_STRATEGY, "fixed-delay");
+        conf.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_ATTEMPTS, 
Integer.MAX_VALUE);
+        conf.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_DELAY, 
Duration.ofSeconds(1));
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(conf);
+        env.setParallelism(2);
+        env.enableCheckpointing(200);
+        DataStreamSource<RowData> source =
+                env.fromSource(
+                        new ContinuousSource(), 
WatermarkStrategy.noWatermarks(), "retag-source");
+        // forward + same parallelism so each source/fail-injector/writer 
triple is its own failover
+        // region; a single subtask's failure restarts only its region, 
keeping the coordinator up.
+        DataStream<RowData> injected =
+                source.forward()
+                        .transform(
+                                "fail-on-savepoint",
+                                InternalTypeInfo.of(RowType.of(new IntType(), 
new VarCharType())),
+                                new FailOnSavepointOperator(1))
+                        .setParallelism(2);
+        new FlinkSinkBuilder(table).forRowData(injected).build();
+        JobClient client = env.executeAsync("aborted-savepoint-retag");
+        try {
+            waitUntilSnapshotWithData(table);
+
+            // Savepoint S: subtask 1 throws while snapshotting it, which 
aborts S and triggers a
+            // region failover; subtask 0 survives with S's savepoint bit 
still buffered. The
+            // trigger
+            // future must fail with the savepoint being interrupted by that 
failover (the
+            // checkpoint
+            // coordinator suspends), not some unrelated error. The injected 
exception is not on
+            // this
+            // chain: it fails the task rather than propagating to the trigger 
future, so
+            // CheckpointException is the tightest type we can assert here.
+            assertThatThrownBy(
+                            () ->
+                                    client.triggerSavepoint(
+                                                    
getTempDirPath("savepoint_" + tableName),
+                                                    
SavepointFormatType.DEFAULT)
+                                            .get(60, TimeUnit.SECONDS))
+                    .satisfies(
+                            e ->
+                                    assertThat(
+                                                    
ExceptionUtils.findThrowable(
+                                                            e, 
CheckpointException.class))
+                                            .isPresent());
+            assertThat(savepointTags(table)).isEmpty();
+
+            // The commit callback throws once on the first post-recovery 
commit, forcing a global
+            // failover whose latest completed checkpoint C carries subtask 
0's stale savepoint bit.
+            waitUntilCallbackFired();
+            // After the global failover the coordinator restores from C, 
replays the stale bit, and
+            // (before the fix) would recreate the orphan tag during 
recover(). Wait for two fresh
+            // post-restore commits so any restore-time tag work has fully run 
before we assert.
+            waitUntilCommittedFurther(table, 2);
+
+            // No savepoint tag must exist: S was aborted, so its tag would be 
an orphan pointing at
+            // a savepoint that no longer exists.
+            assertThat(savepointTags(table)).isEmpty();
+        } finally {
+            client.cancel().get(30, TimeUnit.SECONDS);
+        }
+    }
+
+    private FileStoreTable createTable(String tableName, boolean 
coordinatorCommit)
+            throws Exception {
+        return createTable(tableName, coordinatorCommit, null);
+    }
+
+    private FileStoreTable createTable(
+            String tableName, boolean coordinatorCommit, String 
commitCallbackClass)
+            throws Exception {
+        TableEnvironment tEnv =
+                TableEnvironment.create(
+                        
EnvironmentSettings.newInstance().inStreamingMode().build());
+        tEnv.executeSql(
+                "CREATE CATALOG mycat WITH ( 'type' = 'paimon', 'warehouse' = 
'"
+                        + getTempDirPath()
+                        + "' )");
+        tEnv.executeSql("USE CATALOG mycat");
+        // A stable operator-uid suffix so the writer's state maps back on 
restore-from-savepoint.
+        String coordinatorOption =
+                coordinatorCommit
+                        ? ", 'sink.coordinator-commit.enabled' = 'true', 
'write-only' = 'true'"
+                        : "";
+        String commitCallbackOption =
+                commitCallbackClass == null
+                        ? ""
+                        : ", 'commit.callbacks' = '" + commitCallbackClass + 
"'";
+        tEnv.executeSql(
+                "CREATE TABLE "
+                        + tableName
+                        + " (id INT, data STRING) WITH ("
+                        + "'bucket' = '-1', "
+                        + "'sink.savepoint.auto-tag' = 'true', "
+                        + "'commit.force-create-snapshot' = 'true', "
+                        + "'sink.operator-uid.suffix' = 'failover-tag'"
+                        + coordinatorOption
+                        + commitCallbackOption
+                        + ")");
+        return (FileStoreTable)
+                ((FlinkCatalog) tEnv.getCatalog("mycat").get())
+                        .catalog()
+                        .getTable(Identifier.create("default", tableName));
+    }
+
+    /** Runs the sink job at parallelism 1, optionally resuming from {@code 
savepointPath}. */
+    private JobClient runSink(FileStoreTable table, String savepointPath) 
throws Exception {
+        return runSink(table, savepointPath, false);
+    }
+
+    /**
+     * Runs the sink job at parallelism 1, optionally resuming from {@code 
savepointPath}. When
+     * {@code blockCheckpointAfterSavepoint} is set, a {@link 
BlockCheckpointAfterSavepointOperator}
+     * is chained in so that any normal checkpoint taken after a savepoint 
stalls forever instead of
+     * completing — used to keep a savepoint's tag from being materialized by 
a later checkpoint.
+     */
+    private JobClient runSink(
+            FileStoreTable table, String savepointPath, boolean 
blockCheckpointAfterSavepoint)
+            throws Exception {
+        Configuration conf = new Configuration();
+        if (savepointPath != null) {
+            SavepointRestoreSettings.toConfiguration(
+                    SavepointRestoreSettings.forPath(savepointPath, false), 
conf);
+        }
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(conf);
+        env.setParallelism(1);
+        env.enableCheckpointing(200);
+        DataStreamSource<RowData> source =
+                env.fromSource(
+                        new ContinuousSource(), 
WatermarkStrategy.noWatermarks(), "restore-source");
+        DataStream<RowData> stream = source;
+        if (blockCheckpointAfterSavepoint) {
+            stream =
+                    source.forward()
+                            .transform(
+                                    "block-checkpoint-after-savepoint",
+                                    InternalTypeInfo.of(
+                                            RowType.of(new IntType(), new 
VarCharType())),
+                                    new 
BlockCheckpointAfterSavepointOperator());
+        }
+        new FlinkSinkBuilder(table).forRowData(stream).build();
+        return env.executeAsync("savepoint-failover-tag");
+    }
+
+    private void waitUntilSnapshotWithData(FileStoreTable table) throws 
Exception {
+        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+        while (System.currentTimeMillis() < deadline) {
+            Snapshot latest = table.snapshotManager().latestSnapshot();
+            if (latest != null && latest.totalRecordCount() > 0) {
+                return;
+            }
+            Thread.sleep(200);
+        }
+        throw new IllegalStateException("no data-carrying snapshot committed 
within timeout");
+    }
+
+    /** Waits until a snapshot committed after the current latest one, proving 
the job resumed. */
+    private void waitUntilRecoveredAndCommitting(FileStoreTable table) throws 
Exception {
+        Long baseline = table.snapshotManager().latestSnapshotId();
+        long base = baseline == null ? 0L : baseline;
+        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+        while (System.currentTimeMillis() < deadline) {
+            Long latest = table.snapshotManager().latestSnapshotId();
+            if (latest != null && latest > base) {
+                return;
+            }
+            Thread.sleep(200);
+        }
+        throw new IllegalStateException("job did not resume committing after 
region failover");
+    }
+
+    /**
+     * Waits until at least {@code count} more snapshots commit past the 
current latest.
+     * Restore-time tag work runs before the resumed job commits again, so 
requiring several fresh
+     * commits gives that work room to fully settle before we assert on tags.
+     */
+    private void waitUntilCommittedFurther(FileStoreTable table, int count) 
throws Exception {
+        Long baseline = table.snapshotManager().latestSnapshotId();
+        long target = (baseline == null ? 0L : baseline) + count;
+        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+        while (System.currentTimeMillis() < deadline) {
+            Long latest = table.snapshotManager().latestSnapshotId();
+            if (latest != null && latest >= target) {
+                return;
+            }
+            Thread.sleep(200);
+        }
+        throw new IllegalStateException("job did not commit enough snapshots 
after restore");
+    }
+
+    private void waitUntilCallbackFired() throws Exception {
+        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+        while (!FailOnFirstPostRecoveryCommitCallback.hasFired()
+                && System.currentTimeMillis() < deadline) {
+            Thread.sleep(200);
+        }
+        if (!FailOnFirstPostRecoveryCommitCallback.hasFired()) {
+            throw new IllegalStateException(
+                    "commit callback never fired; the global failover was not 
triggered");
+        }
+    }
+
+    private Map<Snapshot, List<String>> 
waitUntilSavepointTagCreated(FileStoreTable table)
+            throws Exception {
+        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+        Map<Snapshot, List<String>> tags = savepointTags(table);
+        while (tags.isEmpty() && System.currentTimeMillis() < deadline) {
+            Thread.sleep(200);
+            tags = savepointTags(table);
+        }
+        assertThat(tags).describedAs("no savepoint tag was 
created").isNotEmpty();
+        return tags;
+    }
+
+    private Map<Snapshot, List<String>> savepointTags(FileStoreTable table) {
+        return table.tagManager().tags(name -> 
name.startsWith(SavepointTagUtils.PREFIX));
+    }
+
+    /** Emits one row per poll so every checkpoint window carries data. */
+    private static class ContinuousSource extends 
AbstractNonCoordinatedSource<RowData> {
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        public Boundedness getBoundedness() {
+            return Boundedness.CONTINUOUS_UNBOUNDED;
+        }
+
+        @Override
+        public SourceReader<RowData, SimpleSourceSplit> 
createReader(SourceReaderContext ctx) {
+            return new AbstractNonCoordinatedSourceReader<RowData>() {
+                private int next;
+
+                @Override
+                public InputStatus pollNext(ReaderOutput<RowData> output)
+                        throws InterruptedException {
+                    output.collect(GenericRowData.of(next, 
StringData.fromString("v" + next)));
+                    next++;
+                    Thread.sleep(20);
+                    return InputStatus.MORE_AVAILABLE;
+                }
+            };
+        }
+    }
+
+    /**
+     * Passthrough operator that throws exactly once, on the target subtask, 
while a savepoint is
+     * being taken. This reproduces "some writers finished snapshotState, one 
did not", which forces
+     * a region failover with the savepoint in flight.
+     */
+    private static class FailOnSavepointOperator extends 
AbstractStreamOperator<RowData>
+            implements OneInputStreamOperator<RowData, RowData> {
+        private static final long serialVersionUID = 1L;
+        private static final AtomicBoolean FAILED = new AtomicBoolean(false);
+        // Checkpoint id of the savepoint that was aborted, published by the 
throwing subtask so the
+        // commit callback can tell "past the aborted savepoint" from ordinary 
earlier commits.
+        private static volatile long savepointCheckpointId = -1L;
+        private final int targetSubtask;
+
+        FailOnSavepointOperator(int targetSubtask) {
+            this.targetSubtask = targetSubtask;
+        }
+
+        static void reset() {
+            FAILED.set(false);
+            savepointCheckpointId = -1L;
+        }
+
+        static long savepointCheckpointId() {
+            return savepointCheckpointId;
+        }
+
+        @Override
+        public void processElement(StreamRecord<RowData> element) {
+            output.collect(element);
+        }
+
+        @Override
+        public OperatorSnapshotFutures snapshotState(
+                long checkpointId,
+                long timestamp,
+                CheckpointOptions checkpointOptions,
+                CheckpointStreamFactory storageLocation)
+                throws Exception {
+            int subtask = 
getRuntimeContext().getTaskInfo().getIndexOfThisSubtask();
+            if (checkpointOptions.getCheckpointType().isSavepoint()
+                    && subtask == targetSubtask
+                    && FAILED.compareAndSet(false, true)) {
+                savepointCheckpointId = checkpointId;
+                throw new RuntimeException(
+                        "intentional region-failover trigger on subtask " + 
subtask);
+            }
+            return super.snapshotState(checkpointId, timestamp, 
checkpointOptions, storageLocation);
+        }
+    }
+
+    /**
+     * Passthrough operator that lets a savepoint through, but blocks every 
normal checkpoint taken
+     * afterwards by parking in {@code snapshotState} forever. The blocked 
checkpoint neither
+     * completes nor declines, so it cannot materialize a savepoint tag and — 
unlike throwing — does
+     * not trip a failover; it only ends when the job is cancelled (which 
interrupts this thread).
+     * Armed per test through a static flag so the topology-sharing sibling 
case is unaffected.
+     */
+    private static class BlockCheckpointAfterSavepointOperator
+            extends AbstractStreamOperator<RowData>
+            implements OneInputStreamOperator<RowData, RowData> {
+        private static final long serialVersionUID = 1L;
+        private static volatile boolean armed = false;
+        private static volatile boolean savepointSeen = false;
+
+        static void armBlocking() {
+            armed = true;
+            savepointSeen = false;
+        }
+
+        static void disarmBlocking() {
+            armed = false;
+            savepointSeen = false;
+        }
+
+        @Override
+        public void processElement(StreamRecord<RowData> element) {
+            output.collect(element);
+        }
+
+        @Override
+        public OperatorSnapshotFutures snapshotState(
+                long checkpointId,
+                long timestamp,
+                CheckpointOptions checkpointOptions,
+                CheckpointStreamFactory storageLocation)
+                throws Exception {
+            if (armed) {
+                if (checkpointOptions.getCheckpointType().isSavepoint()) {
+                    savepointSeen = true;
+                } else if (savepointSeen) {
+                    // Park until the job is cancelled; the interrupt ends the 
wait.
+                    synchronized (this) {
+                        while (true) {
+                            wait();
+                        }
+                    }
+                }
+            }
+            return super.snapshotState(checkpointId, timestamp, 
checkpointOptions, storageLocation);
+        }
+    }
+
+    /**
+     * Commit callback loaded by {@code commit.callbacks} that throws exactly 
once, on the first
+     * commit whose identifier is past the aborted savepoint's checkpoint id. 
That commit is the
+     * first normal checkpoint after the region failover, so throwing there 
forces a global failover
+     * whose latest completed checkpoint carries the surviving writer's stale 
savepoint bit. Loaded
+     * reflectively by class name, so it must be public with a no-arg 
constructor and coordinate
+     * through static fields (writer TM and JM coordinator share one JVM under 
MiniCluster).
+     */
+    public static class FailOnFirstPostRecoveryCommitCallback implements 
CommitCallback {
+        private static final AtomicBoolean FIRED = new AtomicBoolean(false);
+
+        public FailOnFirstPostRecoveryCommitCallback() {}
+
+        static void reset() {
+            FIRED.set(false);
+        }
+
+        static boolean hasFired() {
+            return FIRED.get();
+        }
+
+        @Override
+        public void call(Context context) {
+            long savepointId = FailOnSavepointOperator.savepointCheckpointId();
+            if (savepointId > 0
+                    && context.identifier > savepointId
+                    && FIRED.compareAndSet(false, true)) {
+                throw new RuntimeException(
+                        "intentional global-failover trigger on commit " + 
context.identifier);
+            }
+        }
+
+        @Override
+        public void retry(ManifestCommittable committable) {}
+
+        @Override
+        public void close() {}
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/AppendTableSavepointTagITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/AppendTableSavepointTagITCase.java
new file mode 100644
index 0000000000..759f5567bf
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/AppendTableSavepointTagITCase.java
@@ -0,0 +1,248 @@
+/*
+ * 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;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.sink.FlinkSinkBuilder;
+import org.apache.paimon.flink.sink.SavepointTagUtils;
+import org.apache.paimon.flink.source.AbstractNonCoordinatedSource;
+import org.apache.paimon.flink.source.AbstractNonCoordinatedSourceReader;
+import org.apache.paimon.flink.source.SimpleSourceSplit;
+import org.apache.paimon.flink.util.AbstractTestBase;
+import org.apache.paimon.table.FileStoreTable;
+
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.ReaderOutput;
+import org.apache.flink.api.connector.source.SourceReader;
+import org.apache.flink.api.connector.source.SourceReaderContext;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.core.execution.SavepointFormatType;
+import org.apache.flink.core.io.InputStatus;
+import org.apache.flink.streaming.api.datastream.DataStreamSource;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.table.api.EnvironmentSettings;
+import org.apache.flink.table.api.TableEnvironment;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end savepoint auto-tag tests for unaware-bucket append tables, 
parameterized over the two
+ * commit paths (coordinator-commit and the classic global committer). Both 
must create the same
+ * {@code savepoint-<checkpointId>} tag for a triggered savepoint.
+ *
+ * <p>Uses a source that emits continuously so the async savepoint 
deterministically lands on a
+ * data-carrying checkpoint; the empty-savepoint boundary (a separate, shared 
limitation) is
+ * intentionally avoided here.
+ */
+public class AppendTableSavepointTagITCase extends AbstractTestBase {
+
+    // The savepoint tag only materializes once a checkpoint *after* the 
savepoint completes and
+    // cumulatively commits the savepoint's snapshot (same catch-up as the 
classic path). Give the
+    // poll generous headroom so a transient checkpoint stall under load 
cannot trip the assertion.
+    private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
+
+    @ParameterizedTest(name = "coordinatorCommit = {0}")
+    @ValueSource(booleans = {true, false})
+    @Timeout(value = 180, unit = TimeUnit.SECONDS)
+    public void testSavepointCreatesTag(boolean coordinatorCommit) throws 
Exception {
+        String tableName = coordinatorCommit ? "T_COORD" : "T_CLASSIC";
+        FileStoreTable table = createTable(tableName, coordinatorCommit);
+
+        JobClient client = runSink(table);
+        try {
+            // Wait until a data-carrying snapshot exists so the async 
savepoint that follows
+            // deterministically lands on a checkpoint that carries data.
+            waitUntilSnapshotWithData(table);
+
+            client.triggerSavepoint(
+                            getTempDirPath("savepoint_" + tableName), 
SavepointFormatType.DEFAULT)
+                    .get(60, TimeUnit.SECONDS);
+
+            // Poll until exactly one savepoint-prefixed tag appears, then 
assert it is consistent
+            // with the snapshot it points at.
+            Map<Snapshot, List<String>> savepointTags = 
waitUntilSavepointTagCreated(table);
+            assertThat(savepointTags).hasSize(1);
+            Map.Entry<Snapshot, List<String>> snapshotWithTags =
+                    savepointTags.entrySet().iterator().next();
+            Snapshot tagged = snapshotWithTags.getKey();
+            assertThat(snapshotWithTags.getValue())
+                    
.containsExactly(SavepointTagUtils.tagNameOf(tagged.commitIdentifier()));
+            
assertThat(table.snapshotManager().snapshotExists(tagged.id())).isTrue();
+        } finally {
+            client.cancel().get(30, TimeUnit.SECONDS);
+        }
+    }
+
+    /**
+     * A sync savepoint (stop-with-savepoint) receives its own {@code 
notifyCheckpointComplete},
+     * unlike an async savepoint, so the tag is created for the savepoint's 
own snapshot rather than
+     * caught up by a later checkpoint. Both commit paths must still produce 
the same tag.
+     *
+     * <p>Disabled for now: on the coordinator-commit path this is racy. The 
coordinator creates the
+     * tag asynchronously on its single-thread commit executor 
(notifyCheckpointComplete ->
+     * tagUpTo), but stop-with-savepoint terminates the job right after the 
savepoint, and the
+     * coordinator's {@code close()} calls {@code 
commitExecutor.shutdownNow()}, which can drop the
+     * not-yet-run tag task so the tag is silently lost. The classic operator 
path is unaffected
+     * because it tags synchronously. Re-enable once the coordinator drains 
pending commit/tag work
+     * on end-of-input shutdown (the follow-up PR that adds proper end-input 
handling to the
+     * coordinator).
+     */
+    // TODO: enable once the coordinator supports end-input handling (drains 
pending tag work).
+    @Disabled(
+            "Coordinator-commit stop-with-savepoint drops the async tag on 
shutdownNow; re-enable"
+                    + " after the coordinator end-input handling PR drains 
pending tag work")
+    @ParameterizedTest(name = "coordinatorCommit = {0}")
+    @ValueSource(booleans = {true, false})
+    @Timeout(value = 180, unit = TimeUnit.SECONDS)
+    public void testStopWithSavepointCreatesTag(boolean coordinatorCommit) 
throws Exception {
+        String tableName = coordinatorCommit ? "T_COORD_STOP" : 
"T_CLASSIC_STOP";
+        FileStoreTable table = createTable(tableName, coordinatorCommit);
+
+        JobClient client = runSink(table);
+        // Wait until a data-carrying snapshot exists so the savepoint lands 
on a checkpoint that
+        // carries data, avoiding the empty-savepoint boundary.
+        waitUntilSnapshotWithData(table);
+
+        // stop-with-savepoint (non-drain): the job terminates after the 
savepoint, so there is no
+        // later checkpoint to fall back on — the tag must come from the 
savepoint's own completion.
+        client.stopWithSavepoint(
+                        false,
+                        getTempDirPath("stop_savepoint_" + tableName),
+                        SavepointFormatType.DEFAULT)
+                .get(120, TimeUnit.SECONDS);
+
+        Map<Snapshot, List<String>> savepointTags = 
waitUntilSavepointTagCreated(table);
+        assertThat(savepointTags).hasSize(1);
+        Map.Entry<Snapshot, List<String>> snapshotWithTags =
+                savepointTags.entrySet().iterator().next();
+        Snapshot tagged = snapshotWithTags.getKey();
+        assertThat(snapshotWithTags.getValue())
+                
.containsExactly(SavepointTagUtils.tagNameOf(tagged.commitIdentifier()));
+        
assertThat(table.snapshotManager().snapshotExists(tagged.id())).isTrue();
+    }
+
+    private FileStoreTable createTable(String tableName, boolean 
coordinatorCommit)
+            throws Exception {
+        TableEnvironment tEnv =
+                TableEnvironment.create(
+                        
EnvironmentSettings.newInstance().inStreamingMode().build());
+        tEnv.executeSql(
+                "CREATE CATALOG mycat WITH ( 'type' = 'paimon', 'warehouse' = 
'"
+                        + getTempDirPath()
+                        + "' )");
+        tEnv.executeSql("USE CATALOG mycat");
+        // force-create-snapshot ensures every completed checkpoint yields a 
snapshot, keeping the
+        // DDL identical to the restore-tag parity IT.
+        String coordinatorOption =
+                coordinatorCommit
+                        ? ", 'sink.coordinator-commit.enabled' = 'true', 
'write-only' = 'true'"
+                        : "";
+        tEnv.executeSql(
+                "CREATE TABLE "
+                        + tableName
+                        + " (id INT, data STRING) WITH ("
+                        + "'bucket' = '-1', "
+                        + "'sink.savepoint.auto-tag' = 'true', "
+                        + "'commit.force-create-snapshot' = 'true'"
+                        + coordinatorOption
+                        + ")");
+        return (FileStoreTable)
+                ((FlinkCatalog) tEnv.getCatalog("mycat").get())
+                        .catalog()
+                        .getTable(Identifier.create("default", tableName));
+    }
+
+    private JobClient runSink(FileStoreTable table) throws Exception {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.setParallelism(1);
+        env.enableCheckpointing(200);
+        DataStreamSource<RowData> stream =
+                env.fromSource(
+                        new ContinuousSource(), 
WatermarkStrategy.noWatermarks(), "tag-source");
+        new FlinkSinkBuilder(table).forRowData(stream).build();
+        return env.executeAsync("savepoint-tag");
+    }
+
+    private void waitUntilSnapshotWithData(FileStoreTable table) throws 
Exception {
+        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+        while (System.currentTimeMillis() < deadline) {
+            Snapshot latest = table.snapshotManager().latestSnapshot();
+            if (latest != null && latest.totalRecordCount() > 0) {
+                return;
+            }
+            Thread.sleep(200);
+        }
+        throw new IllegalStateException("no data-carrying snapshot committed 
within timeout");
+    }
+
+    private Map<Snapshot, List<String>> 
waitUntilSavepointTagCreated(FileStoreTable table)
+            throws Exception {
+        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+        Map<Snapshot, List<String>> tags = savepointTags(table);
+        while (tags.isEmpty() && System.currentTimeMillis() < deadline) {
+            Thread.sleep(200);
+            tags = savepointTags(table);
+        }
+        assertThat(tags).describedAs("no savepoint tag was 
created").isNotEmpty();
+        return tags;
+    }
+
+    private Map<Snapshot, List<String>> savepointTags(FileStoreTable table) {
+        return table.tagManager().tags(name -> 
name.startsWith(SavepointTagUtils.PREFIX));
+    }
+
+    /** Emits one row per poll so every checkpoint window carries data. */
+    private static class ContinuousSource extends 
AbstractNonCoordinatedSource<RowData> {
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        public Boundedness getBoundedness() {
+            return Boundedness.CONTINUOUS_UNBOUNDED;
+        }
+
+        @Override
+        public SourceReader<RowData, SimpleSourceSplit> 
createReader(SourceReaderContext ctx) {
+            return new AbstractNonCoordinatedSourceReader<RowData>() {
+                private int next;
+
+                @Override
+                public InputStatus pollNext(ReaderOutput<RowData> output)
+                        throws InterruptedException {
+                    output.collect(GenericRowData.of(next, 
StringData.fromString("v" + next)));
+                    next++;
+                    Thread.sleep(20);
+                    return InputStatus.MORE_AVAILABLE;
+                }
+            };
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperatorTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperatorTest.java
index bbd79573ec..1ebddec2bf 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperatorTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/AutoTagForSavepointCommitterOperatorTest.java
@@ -24,6 +24,7 @@ import org.apache.paimon.data.InternalRow;
 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.StreamTableCommit;
 import org.apache.paimon.table.sink.StreamTableWrite;
 import org.apache.paimon.utils.ThrowingConsumer;
 
@@ -91,8 +92,7 @@ public class AutoTagForSavepointCommitterOperatorTest extends 
CommitterOperatorT
         assertThat(snapshot.id()).isEqualTo(2);
         Map<Snapshot, List<String>> tags = table.tagManager().tags();
         assertThat(tags).containsOnlyKeys(snapshot);
-        assertThat(tags.get(snapshot))
-                
.containsOnly(AutoTagForSavepointCommitterOperator.SAVEPOINT_TAG_PREFIX + 2);
+        
assertThat(tags.get(snapshot)).containsOnly(SavepointTagUtils.tagNameOf(2));
     }
 
     @Test
@@ -142,9 +142,7 @@ public class AutoTagForSavepointCommitterOperatorTest 
extends CommitterOperatorT
 
         Map<Snapshot, List<String>> tags = table.tagManager().tags();
         assertThat(tags).containsOnlyKeys(snapshot);
-        assertThat(tags.get(snapshot))
-                .containsOnly(
-                        
AutoTagForSavepointCommitterOperator.SAVEPOINT_TAG_PREFIX + checkpointId);
+        
assertThat(tags.get(snapshot)).containsOnly(SavepointTagUtils.tagNameOf(checkpointId));
     }
 
     @Test
@@ -181,6 +179,39 @@ public class AutoTagForSavepointCommitterOperatorTest 
extends CommitterOperatorT
         assertThat(table.tagManager().tagCount()).isEqualTo(0);
     }
 
+    @Test
+    public void testAbortCheckpointKeepsTagFromDifferentCommitUser() throws 
Exception {
+        FileStoreTable table = createFileStoreTable();
+        createSavepointTag(table, initialCommitUser + "-other", 1L);
+
+        OneInputStreamOperatorTestHarness<Committable, Committable> 
testHarness =
+                createRecoverableTestHarness(table);
+        testHarness.open();
+        testHarness.getOneInputOperator().notifyCheckpointAborted(1L);
+        testHarness.close();
+
+        
assertThat(table.tagManager().tagExists(SavepointTagUtils.tagNameOf(1L))).isTrue();
+    }
+
+    private void createSavepointTag(FileStoreTable table, String commitUser, 
long commitIdentifier)
+            throws Exception {
+        try (StreamTableWrite write =
+                        
table.newStreamWriteBuilder().withCommitUser(commitUser).newWrite();
+                StreamTableCommit commit =
+                        
table.newStreamWriteBuilder().withCommitUser(commitUser).newCommit()) {
+            write.write(GenericRow.of(1, 10L));
+            List<CommitMessage> messages = write.prepareCommit(false, 
commitIdentifier);
+            commit.commit(commitIdentifier, messages);
+        }
+        table.tagManager()
+                .createTag(
+                        table.snapshotManager().latestSnapshot(),
+                        SavepointTagUtils.tagNameOf(commitIdentifier),
+                        table.coreOptions().tagDefaultTimeRetained(),
+                        table.store().createTagCallbacks(table),
+                        false);
+    }
+
     private void processCommittable(
             OneInputStreamOperatorTestHarness<Committable, Committable> 
testHarness,
             StreamTableWrite write,
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 c533e175fa..b139aabe88 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
@@ -73,7 +73,7 @@ import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.fail;
 
 /** Tests for {@link CommitterOperator}. */
-public class CommitterOperatorTest extends CommitterOperatorTestBase {
+public class CommitterOperatorTest extends CommitterTestBase {
 
     protected String initialCommitUser;
 
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterOperatorTestBase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterTestBase.java
similarity index 96%
rename from 
paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterOperatorTestBase.java
rename to 
paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterTestBase.java
index a69f8dbd3a..32f7b2b1da 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterOperatorTestBase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CommitterTestBase.java
@@ -46,8 +46,10 @@ import java.util.function.Consumer;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
-/** Base test class for {@link CommitterOperatorTest}. */
-public abstract class CommitterOperatorTestBase {
+/**
+ * Base test class providing an unaware/fixed-bucket {@link FileStoreTable} 
and result assertions.
+ */
+public abstract class CommitterTestBase {
 
     private static final RowType ROW_TYPE =
             RowType.of(
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
index 89911cc271..935f3bc99f 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
@@ -34,9 +34,11 @@ import org.apache.paimon.table.sink.CommitMessageSerializer;
 import org.apache.flink.api.common.ExecutionConfig;
 import org.apache.flink.api.common.JobID;
 import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.core.execution.SavepointFormatType;
 import org.apache.flink.core.io.SimpleVersionedSerializerTypeSerializerProxy;
 import org.apache.flink.runtime.checkpoint.CheckpointCoordinator;
 import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
+import org.apache.flink.runtime.checkpoint.SavepointType;
 import org.apache.flink.runtime.checkpoint.TaskStateSnapshot;
 import org.apache.flink.runtime.jobgraph.OperatorID;
 import org.apache.flink.runtime.operators.coordination.CoordinatorStore;
@@ -64,7 +66,7 @@ import java.util.UUID;
 import static org.assertj.core.api.Assertions.assertThat;
 
 /** Tests for {@link CoordinatorCommittingRowDataStoreWriteOperator}. */
-public class CoordinatorCommittingRowDataStoreWriteOperatorTest extends 
CommitterOperatorTestBase {
+public class CoordinatorCommittingRowDataStoreWriteOperatorTest extends 
CommitterTestBase {
 
     private static final TypeSerializer<CheckpointCommittables> 
COMMITTABLES_SERIALIZER =
             new SimpleVersionedSerializerTypeSerializerProxy<>(
@@ -91,7 +93,8 @@ public class 
CoordinatorCommittingRowDataStoreWriteOperatorTest extends Committe
                                 new StoreCommitter(
                                         table, 
table.newCommit(context.commitUser()), context),
                         true,
-                        commitUser);
+                        commitUser,
+                        null);
         coordinator.start();
         coordinator.waitProcessAllActions();
 
@@ -442,6 +445,121 @@ public class 
CoordinatorCommittingRowDataStoreWriteOperatorTest extends Committe
         secondHarness.close();
     }
 
+    @Test
+    @Timeout(30)
+    public void testSavepointBitRidesOnCommittableEventAndPendingState() 
throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        String commitUser = UUID.randomUUID().toString();
+        List<OperatorEvent> events = new ArrayList<>();
+
+        OneInputStreamOperatorTestHarness<InternalRow, Committable> harness =
+                createHarness(table, commitUser, events::add);
+        TypeSerializer<Committable> committableSerializer =
+                new CommittableTypeInfo().createSerializer(new 
ExecutionConfig());
+        harness.setup(committableSerializer);
+        harness.open();
+        CoordinatorCommittingRowDataStoreWriteOperator operator =
+                (CoordinatorCommittingRowDataStoreWriteOperator) 
harness.getOperator();
+
+        // cp1: a normal checkpoint carries savepoint=false in both the event 
and the pending state.
+        harness.processElement(GenericRow.of(1, 10L), 1);
+        harness.prepareSnapshotPreBarrier(1);
+        harness.snapshot(1, 10);
+        assertThat(
+                        ((CommittableEvent) events.get(0))
+                                .deserialize(COMMITTABLES_SERIALIZER)
+                                .shouldCreateSavepointTag())
+                .isFalse();
+        
assertThat(operator.getPendingCommittables().get(1L).shouldCreateSavepointTag()).isFalse();
+
+        // cp2: a savepoint sets savepoint=true on both the emitted event and 
the persisted entry.
+        harness.processElement(GenericRow.of(2, 20L), 2);
+        harness.prepareSnapshotPreBarrier(2);
+        harness.snapshotWithLocalState(
+                2, 20, SavepointType.savepoint(SavepointFormatType.CANONICAL));
+        assertThat(
+                        ((CommittableEvent) events.get(1))
+                                .deserialize(COMMITTABLES_SERIALIZER)
+                                .shouldCreateSavepointTag())
+                .isTrue();
+        
assertThat(operator.getPendingCommittables().get(2L).shouldCreateSavepointTag()).isTrue();
+
+        harness.close();
+    }
+
+    @Test
+    @Timeout(30)
+    public void testSavepointBitStaysFalseWhenAutoTagDisabled() throws 
Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        String commitUser = UUID.randomUUID().toString();
+        List<OperatorEvent> events = new ArrayList<>();
+
+        OneInputStreamOperatorTestHarness<InternalRow, Committable> harness =
+                createHarness(table, commitUser, events::add, /* 
autoTagForSavepoint */ false);
+        TypeSerializer<Committable> committableSerializer =
+                new CommittableTypeInfo().createSerializer(new 
ExecutionConfig());
+        harness.setup(committableSerializer);
+        harness.open();
+        CoordinatorCommittingRowDataStoreWriteOperator operator =
+                (CoordinatorCommittingRowDataStoreWriteOperator) 
harness.getOperator();
+
+        // Even on a savepoint, a writer without auto-tag enabled must not 
flag a tag intent: the
+        // checkpoint is a savepoint, but no savepoint tag should be created 
for it.
+        harness.processElement(GenericRow.of(1, 10L), 1);
+        harness.prepareSnapshotPreBarrier(1);
+        harness.snapshotWithLocalState(
+                1, 10, SavepointType.savepoint(SavepointFormatType.CANONICAL));
+        assertThat(
+                        ((CommittableEvent) events.get(0))
+                                .deserialize(COMMITTABLES_SERIALIZER)
+                                .shouldCreateSavepointTag())
+                .isFalse();
+        
assertThat(operator.getPendingCommittables().get(1L).shouldCreateSavepointTag()).isFalse();
+
+        harness.close();
+    }
+
+    @Test
+    @Timeout(30)
+    public void testSavepointBitReplayedOnRestore() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        String commitUser = UUID.randomUUID().toString();
+        TypeSerializer<Committable> committableSerializer =
+                new CommittableTypeInfo().createSerializer(new 
ExecutionConfig());
+
+        // session 1: take a savepoint that is never notified complete, then 
crash.
+        List<OperatorEvent> firstEvents = new ArrayList<>();
+        OneInputStreamOperatorTestHarness<InternalRow, Committable> 
firstHarness =
+                createHarness(table, commitUser, firstEvents::add);
+        firstHarness.setup(committableSerializer);
+        firstHarness.open();
+        firstHarness.processElement(GenericRow.of(1, 10L), 1);
+        firstHarness.prepareSnapshotPreBarrier(1);
+        OperatorSubtaskState snapshot =
+                firstHarness
+                        .snapshotWithLocalState(
+                                1, 10, 
SavepointType.savepoint(SavepointFormatType.CANONICAL))
+                        .getJobManagerOwnedState();
+        firstHarness.close();
+
+        // session 2: restore replays the persisted savepoint bit in the 
RestoredCommittableEvent.
+        List<OperatorEvent> restoredEvents = new ArrayList<>();
+        OneInputStreamOperatorTestHarness<InternalRow, Committable> 
secondHarness =
+                createHarness(table, commitUser, restoredEvents::add);
+        secondHarness.setup(committableSerializer);
+        restoreWithCheckpointId(secondHarness, snapshot, 1L);
+        secondHarness.open();
+
+        assertThat(restoredEvents).hasSize(1);
+        RestoredCommittableEvent restoredEvent = (RestoredCommittableEvent) 
restoredEvents.get(0);
+        List<CheckpointCommittables> entries = 
restoredEvent.deserialize(COMMITTABLES_SERIALIZER);
+        assertThat(entries).hasSize(1);
+        assertThat(entries.get(0).checkpointId()).isEqualTo(1L);
+        assertThat(entries.get(0).shouldCreateSavepointTag()).isTrue();
+
+        secondHarness.close();
+    }
+
     private void assertCommittableEventCheckpoint(OperatorEvent event, long 
expectedCheckpointId) {
         CommittableEvent committableEvent = (CommittableEvent) event;
         
assertThat(committableEvent.getCheckpointId()).isEqualTo(expectedCheckpointId);
@@ -485,6 +603,15 @@ public class 
CoordinatorCommittingRowDataStoreWriteOperatorTest extends Committe
     private OneInputStreamOperatorTestHarness<InternalRow, Committable> 
createHarness(
             FileStoreTable table, String commitUser, OperatorEventGateway 
gateway)
             throws Exception {
+        return createHarness(table, commitUser, gateway, /* 
autoTagForSavepoint */ true);
+    }
+
+    private OneInputStreamOperatorTestHarness<InternalRow, Committable> 
createHarness(
+            FileStoreTable table,
+            String commitUser,
+            OperatorEventGateway gateway,
+            boolean autoTagForSavepoint)
+            throws Exception {
         RowDataStoreWriteOperator.Factory operatorFactory =
                 new RowDataStoreWriteOperator.Factory(
                         table,
@@ -515,7 +642,8 @@ public class 
CoordinatorCommittingRowDataStoreWriteOperatorTest extends Committe
                                         table,
                                         storeSinkWriteProvider,
                                         commitUser,
-                                        gateway);
+                                        gateway,
+                                        autoTagForSavepoint);
                     }
 
                     @Override
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/FlinkSinkTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/FlinkSinkTest.java
index 91110d9962..0df52e25c4 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/FlinkSinkTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/FlinkSinkTest.java
@@ -39,7 +39,7 @@ import java.util.function.Consumer;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link FlinkSink}. */
-public class FlinkSinkTest extends CommitterOperatorTestBase {
+public class FlinkSinkTest extends CommitterTestBase {
 
     private static final RowType ROW_TYPE =
             RowType.of(
@@ -105,17 +105,14 @@ public class FlinkSinkTest extends 
CommitterOperatorTestBase {
     }
 
     @Test
-    public void testCoordinatorCommitPreconditionsRejectsAutoTagForSavepoint() 
throws Exception {
+    public void testCoordinatorCommitPreconditionsAllowsAutoTagForSavepoint() 
throws Exception {
         FileStoreTable table =
                 createUnawareBucketTable(
                         options ->
                                 options.set(
                                         
FlinkConnectorOptions.SINK_AUTO_TAG_FOR_SAVEPOINT, true));
-        assertThatThrownBy(
-                        () ->
-                                FlinkSink.checkCoordinatorCommitPreconditions(
-                                        table, newCheckpointConfig(1), true))
-                .isInstanceOf(IllegalArgumentException.class);
+        // auto-tag-for-savepoint is now supported on the coordinator-commit 
path
+        FlinkSink.checkCoordinatorCommitPreconditions(table, 
newCheckpointConfig(1), true);
     }
 
     @Test
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/SavepointTagUtilsTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/SavepointTagUtilsTest.java
new file mode 100644
index 0000000000..2e858e41c8
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/SavepointTagUtilsTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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.data.GenericRow;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.StreamTableCommit;
+import org.apache.paimon.table.sink.StreamTableWrite;
+import org.apache.paimon.tag.Tag;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+/** Tests for {@link SavepointTagUtils}. */
+public class SavepointTagUtilsTest extends CommitterTestBase {
+
+    @Test
+    public void testIsSavepointTagFor() throws Exception {
+        FileStoreTable table = createFileStoreTable();
+        Tag tag = createSavepointTag(table, "user", 1L, 1L);
+
+        assertThat(SavepointTagUtils.isSavepointTagFor(tag, "user", 
1L)).isTrue();
+        assertThat(SavepointTagUtils.isSavepointTagFor(tag, "other-user", 
1L)).isFalse();
+        assertThat(SavepointTagUtils.isSavepointTagFor(tag, "user", 
2L)).isFalse();
+    }
+
+    @Test
+    public void testIsSavepointTagForWithNullCommitUser() throws Exception {
+        FileStoreTable table = createFileStoreTable();
+        Tag tag = createSavepointTag(table, "user", 1L, 1L);
+
+        assertThatNullPointerException()
+                .isThrownBy(() -> SavepointTagUtils.isSavepointTagFor(tag, 
null, 1L));
+    }
+
+    @Test
+    public void testDeleteTagIfMatches() throws Exception {
+        FileStoreTable table = createFileStoreTable();
+        createSavepointTag(table, "user", 1L, 1L);
+
+        deleteTagIfMatches(table, "user", 1L);
+
+        
assertThat(table.tagManager().tagExists(SavepointTagUtils.tagNameOf(1L))).isFalse();
+    }
+
+    @Test
+    public void testDeleteTagIfMatchesKeepsTagFromDifferentCommitUser() throws 
Exception {
+        FileStoreTable table = createFileStoreTable();
+        createSavepointTag(table, "user", 1L, 1L);
+
+        deleteTagIfMatches(table, "other-user", 1L);
+
+        
assertThat(table.tagManager().tagExists(SavepointTagUtils.tagNameOf(1L))).isTrue();
+    }
+
+    @Test
+    public void testDeleteTagIfMatchesKeepsTagForDifferentCommitIdentifier() 
throws Exception {
+        FileStoreTable table = createFileStoreTable();
+        createSavepointTag(table, "user", 1L, 2L);
+
+        deleteTagIfMatches(table, "user", 1L);
+
+        
assertThat(table.tagManager().tagExists(SavepointTagUtils.tagNameOf(1L))).isTrue();
+    }
+
+    @Test
+    public void testDeleteTagIfMatchesWhenTagDoesNotExist() throws Exception {
+        FileStoreTable table = createFileStoreTable();
+
+        deleteTagIfMatches(table, "user", 1L);
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+    }
+
+    private Tag createSavepointTag(
+            FileStoreTable table,
+            String commitUser,
+            long savepointIdentifier,
+            long snapshotCommitIdentifier)
+            throws Exception {
+        try (StreamTableWrite write =
+                        
table.newStreamWriteBuilder().withCommitUser(commitUser).newWrite();
+                StreamTableCommit commit =
+                        
table.newStreamWriteBuilder().withCommitUser(commitUser).newCommit()) {
+            write.write(GenericRow.of(1, 10L));
+            List<CommitMessage> messages = write.prepareCommit(false, 
snapshotCommitIdentifier);
+            commit.commit(snapshotCommitIdentifier, messages);
+        }
+
+        String tagName = SavepointTagUtils.tagNameOf(savepointIdentifier);
+        table.tagManager()
+                .createTag(
+                        table.snapshotManager().latestSnapshot(),
+                        tagName,
+                        table.coreOptions().tagDefaultTimeRetained(),
+                        table.store().createTagCallbacks(table),
+                        false);
+        return table.tagManager().get(tagName).get();
+    }
+
+    private void deleteTagIfMatches(
+            FileStoreTable table, String commitUser, long commitIdentifier) {
+        SavepointTagUtils.deleteTagIfMatches(
+                table.tagManager(),
+                commitUser,
+                commitIdentifier,
+                table.store().newTagDeletion(),
+                table.snapshotManager(),
+                table.store().createTagCallbacks(table));
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializerTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializerTest.java
index 09b894d03d..e95ee7aef4 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializerTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializerTest.java
@@ -38,8 +38,8 @@ public class CheckpointCommittablesSerializerTest {
                     new CommittableSerializer(new CommitMessageSerializer()));
 
     @Test
-    public void testCurrentVersionIsV2() {
-        assertThat(serializer.getVersion()).isEqualTo(2);
+    public void testCurrentVersionIsV3() {
+        assertThat(serializer.getVersion()).isEqualTo(3);
     }
 
     @Test
@@ -53,10 +53,24 @@ public class CheckpointCommittablesSerializerTest {
             assertThat(decoded.checkpointId()).isEqualTo(42L);
             assertThat(decoded.watermark()).isEqualTo(4242L);
             assertThat(decoded.idle()).isEqualTo(idle);
+            assertThat(decoded.shouldCreateSavepointTag()).isFalse();
             assertThat(decoded.committables()).isEmpty();
         }
     }
 
+    @Test
+    public void testRoundTripPreservesSavepointFlag() throws IOException {
+        for (boolean savepoint : new boolean[] {true, false}) {
+            CheckpointCommittables original =
+                    new CheckpointCommittables(
+                            42L, Collections.emptyList(), /* watermark */ 
4242L, false, savepoint);
+            CheckpointCommittables decoded =
+                    serializer.deserialize(serializer.getVersion(), 
serializer.serialize(original));
+            assertThat(decoded.checkpointId()).isEqualTo(42L);
+            
assertThat(decoded.shouldCreateSavepointTag()).isEqualTo(savepoint);
+        }
+    }
+
     @Test
     public void testV1PayloadDeserializesAsActive() throws IOException {
         // Hand-encode a v1 payload (no idle bit) so the reader is exercised 
on real bytes rather
@@ -73,6 +87,25 @@ public class CheckpointCommittablesSerializerTest {
         // v1 predates idle tracking; readers must default to ACTIVE so 
pre-upgrade payloads keep
         // participating in the min just like they did before.
         assertThat(decoded.idle()).isFalse();
+        assertThat(decoded.shouldCreateSavepointTag()).isFalse();
+        assertThat(decoded.committables()).isEmpty();
+    }
+
+    @Test
+    public void testV2PayloadDeserializesWithSavepointFalse() throws 
IOException {
+        // Hand-encode a v2 payload (idle bit but no savepoint bit) to 
exercise the v2->v3 fallback.
+        DataOutputSerializer out = new DataOutputSerializer(32);
+        out.writeLong(7L); // checkpointId
+        out.writeLong(1234L); // watermark
+        out.writeBoolean(true); // idle
+        out.writeInt(new CommittableSerializer(new 
CommitMessageSerializer()).getVersion());
+        out.writeInt(0); // empty committables
+
+        CheckpointCommittables decoded = serializer.deserialize(2, 
out.getCopyOfBuffer());
+        assertThat(decoded.checkpointId()).isEqualTo(7L);
+        assertThat(decoded.idle()).isTrue();
+        // v2 predates savepoint tracking; default to false (a normal 
checkpoint).
+        assertThat(decoded.shouldCreateSavepointTag()).isFalse();
         assertThat(decoded.committables()).isEmpty();
     }
 
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
index 74f54da3aa..2357eaccc4 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
@@ -27,7 +27,8 @@ import org.apache.paimon.flink.FlinkConnectorOptions;
 import org.apache.paimon.flink.sink.Committable;
 import org.apache.paimon.flink.sink.CommittableSerializer;
 import org.apache.paimon.flink.sink.Committer;
-import org.apache.paimon.flink.sink.CommitterOperatorTestBase;
+import org.apache.paimon.flink.sink.CommitterTestBase;
+import org.apache.paimon.flink.sink.SavepointTagUtils;
 import org.apache.paimon.flink.sink.StoreCommitter;
 import org.apache.paimon.flink.sink.state.CoordinatorState;
 import org.apache.paimon.flink.sink.state.CoordinatorStateSerializer;
@@ -75,7 +76,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Unit tests for {@link CommittingWriteOperatorCoordinator}. */
-public class CommittingWriteOperatorCoordinatorTest extends 
CommitterOperatorTestBase {
+public class CommittingWriteOperatorCoordinatorTest extends CommitterTestBase {
 
     private static final TypeSerializer<CheckpointCommittables> SERIALIZER =
             new SimpleVersionedSerializerTypeSerializerProxy<>(
@@ -376,6 +377,114 @@ public class CommittingWriteOperatorCoordinatorTest 
extends CommitterOperatorTes
         coordinator.close();
     }
 
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    @Test
+    public void testAutoTagForSavepointOnComplete() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        TestingContext context = new TestingContext(new OperatorID(), 1);
+        CommittingWriteOperatorCoordinator coordinator =
+                createCoordinatorWithAutoTag(table, context);
+        coordinator.start();
+        coordinator.waitProcessAllActions();
+
+        // savepoint at cp1 (not notified), then a normal cp2 completes and 
materializes both.
+        coordinator.handleEventFromOperator(
+                0, 0, savepointEvent(1L, 
Collections.singletonList(committable(table, 1, 1))));
+        coordinator.handleEventFromOperator(0, 0, event(committable(table, 2, 
2)));
+        coordinator.notifyCheckpointComplete(2L);
+        coordinator.waitProcessAllActions();
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(1);
+        assertThat(table.tagManager().tagExists(savepointTag(1L))).isTrue();
+        Snapshot tagged = table.snapshotManager().snapshot(1);
+        
assertThat(table.tagManager().tags().get(tagged)).containsOnly(savepointTag(1L));
+        coordinator.close();
+    }
+
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    @Test
+    public void testAbortSavepointRemovesTag() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        TestingContext context = new TestingContext(new OperatorID(), 1);
+        CommittingWriteOperatorCoordinator coordinator =
+                createCoordinatorWithAutoTag(table, context);
+        coordinator.start();
+        coordinator.waitProcessAllActions();
+
+        // savepoint at cp1, then cp2 completes and creates the savepoint-1 
tag.
+        coordinator.handleEventFromOperator(
+                0, 0, savepointEvent(1L, 
Collections.singletonList(committable(table, 1, 1))));
+        coordinator.handleEventFromOperator(0, 0, event(committable(table, 2, 
2)));
+        coordinator.notifyCheckpointComplete(2L);
+        coordinator.waitProcessAllActions();
+        assertThat(table.tagManager().tagCount()).isEqualTo(1);
+
+        // aborting the savepoint removes the tag that a later checkpoint had 
created.
+        coordinator.notifyCheckpointAborted(1L);
+        coordinator.waitProcessAllActions();
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+        coordinator.close();
+    }
+
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    @Test
+    public void testAutoTagForSavepointOnRestore() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        TestingContext context = new TestingContext(new OperatorID(), 1);
+
+        // capture coordinator state holding an uncommitted savepoint at cp1.
+        CommittingWriteOperatorCoordinator first = 
createCoordinatorWithAutoTag(table, context);
+        first.start();
+        first.handleEventFromOperator(
+                0, 0, savepointEvent(1L, 
Collections.singletonList(committable(table, 1, 1))));
+        CompletableFuture<byte[]> checkpoint = new CompletableFuture<>();
+        first.checkpointCoordinator(1, checkpoint);
+        first.waitProcessAllActions();
+        byte[] state = checkpoint.get();
+        first.close();
+        assertThat(table.latestSnapshot()).isNotPresent();
+
+        // restore: the replayed savepoint bit drives the re-commit and tag 
creation while the
+        // coordinator keeps running — no intentional failover.
+        CommittingWriteOperatorCoordinator second = 
createCoordinatorWithAutoTag(table, context);
+        second.resetToCheckpoint(1, state);
+        second.start();
+        second.waitProcessAllActions();
+        second.handleEventFromOperator(
+                0,
+                0,
+                savepointRestoreEvent(1L, 
Collections.singletonList(committable(table, 1, 1))));
+        second.waitProcessAllActions();
+
+        assertThat(failureCause).isNull();
+        assertThat(second.getCurrentState())
+                .isEqualTo(CommittingWriteOperatorCoordinator.State.RUNNING);
+        assertThat(table.tagManager().tagExists(savepointTag(1L))).isTrue();
+        second.close();
+    }
+
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    @Test
+    public void testNoTagWhenAutoTagDisabled() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        TestingContext context = new TestingContext(new OperatorID(), 1);
+        CommittingWriteOperatorCoordinator coordinator = 
createCoordinator(table, context);
+        coordinator.start();
+        coordinator.waitProcessAllActions();
+
+        coordinator.handleEventFromOperator(
+                0, 0, savepointEvent(1L, 
Collections.singletonList(committable(table, 1, 1))));
+        coordinator.notifyCheckpointComplete(1L);
+        coordinator.waitProcessAllActions();
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+        coordinator.close();
+    }
+
+    private static String savepointTag(long checkpointId) {
+        return SavepointTagUtils.tagNameOf(checkpointId);
+    }
+
     @Timeout(value = 30, unit = TimeUnit.SECONDS)
     @Test
     public void testCheckpointFutureCompletedExceptionallyOnSnapshotFailure() 
throws Exception {
@@ -395,7 +504,8 @@ public class CommittingWriteOperatorCoordinatorTest extends 
CommitterOperatorTes
                                                 commitContext),
                                         expected),
                         true,
-                        commitUser);
+                        commitUser,
+                        null);
         coordinator.start();
         coordinator.waitProcessAllActions();
 
@@ -1087,7 +1197,31 @@ public class CommittingWriteOperatorCoordinatorTest 
extends CommitterOperatorTes
                                         .newCommit(),
                                 commitContext),
                 true,
-                commitUser);
+                commitUser,
+                null);
+    }
+
+    private CommittingWriteOperatorCoordinator createCoordinatorWithAutoTag(
+            FileStoreTable table, TestingContext context) {
+        return new CommittingWriteOperatorCoordinator(
+                context,
+                commitContext ->
+                        new StoreCommitter(
+                                table,
+                                table.newStreamWriteBuilder()
+                                        
.withCommitUser(commitContext.commitUser())
+                                        .newCommit(),
+                                commitContext),
+                true,
+                commitUser,
+                user ->
+                        new SavepointTagger(
+                                table.snapshotManager(),
+                                table.tagManager(),
+                                table.store().newTagDeletion(),
+                                table.store().createTagCallbacks(table),
+                                table.coreOptions().tagDefaultTimeRetained(),
+                                user));
     }
 
     private CommittingWriteOperatorCoordinator 
createCoordinatorCapturingContext(
@@ -1106,7 +1240,8 @@ public class CommittingWriteOperatorCoordinatorTest 
extends CommitterOperatorTes
                             commitContext);
                 },
                 true,
-                commitUser);
+                commitUser,
+                null);
     }
 
     private Committable committable(FileStoreTable table, long checkpointId, 
int value)
@@ -1182,6 +1317,34 @@ public class CommittingWriteOperatorCoordinatorTest 
extends CommitterOperatorTes
         return eventOf(checkpointId, Collections.emptyList(), Long.MIN_VALUE);
     }
 
+    private CommittableEvent savepointEvent(long checkpointId, 
List<Committable> committables)
+            throws Exception {
+        return CommittableEvent.create(
+                checkpointId,
+                new CheckpointCommittables(
+                        checkpointId,
+                        committables,
+                        Long.MIN_VALUE,
+                        /* idle */ false,
+                        /* shouldCreateSavepointTag */ true),
+                SERIALIZER);
+    }
+
+    private RestoredCommittableEvent savepointRestoreEvent(
+            long restoredCheckpointId, List<Committable> committables) throws 
Exception {
+        CheckpointCommittables checkpointCommittables =
+                new CheckpointCommittables(
+                        restoredCheckpointId,
+                        committables,
+                        Long.MIN_VALUE,
+                        /* idle */ false,
+                        /* shouldCreateSavepointTag */ true);
+        return RestoredCommittableEvent.create(
+                restoredCheckpointId,
+                Collections.singletonList(checkpointCommittables),
+                SERIALIZER);
+    }
+
     private RestoredCommittableEvent restoreEvent(
             long restoredCheckpointId, Committable committable) throws 
Exception {
         return restoreEventOf(restoredCheckpointId, 
Collections.singletonList(committable));
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/SavepointTaggerTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/SavepointTaggerTest.java
new file mode 100644
index 0000000000..a84bc205c9
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/SavepointTaggerTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.coordinator;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.flink.sink.CommitterTestBase;
+import org.apache.paimon.flink.sink.SavepointTagUtils;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.StreamTableCommit;
+import org.apache.paimon.table.sink.StreamTableWrite;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Unit tests for {@link SavepointTagger}. */
+public class SavepointTaggerTest extends CommitterTestBase {
+
+    private String commitUser;
+
+    @BeforeEach
+    public void before() {
+        super.before();
+        commitUser = UUID.randomUUID().toString();
+    }
+
+    @Test
+    public void testTagUpToCreatesTagForCommittedSavepoint() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        tagger.add(1L);
+        commitSnapshot(table, 1L);
+        commitSnapshot(table, 2L);
+
+        tagger.tagUpTo(2L);
+
+        assertThat(table.tagManager().tagExists(savepointTag(1L))).isTrue();
+        assertThat(table.tagManager().tagCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void testTagUpToBoundaryIsInclusive() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        tagger.add(2L);
+        commitSnapshot(table, 2L);
+
+        // tagUpTo uses headSet(checkpointId, true), so a pending id equal to 
checkpointId is
+        // tagged.
+        tagger.tagUpTo(2L);
+
+        assertThat(table.tagManager().tagExists(savepointTag(2L))).isTrue();
+    }
+
+    @Test
+    public void testTagUpToLeavesLaterPendingUntagged() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        tagger.add(5L);
+        commitSnapshot(table, 5L);
+
+        // 5 is above the checkpoint watermark, so it stays pending and no tag 
is created.
+        tagger.tagUpTo(4L);
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+    }
+
+    @Test
+    public void testTagUpToWithNoPendingCreatesNothing() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        commitSnapshot(table, 1L);
+
+        tagger.tagUpTo(1L);
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+    }
+
+    @Test
+    public void testTagUpToIsIdempotent() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        tagger.add(1L);
+        commitSnapshot(table, 1L);
+        commitSnapshot(table, 2L);
+
+        // A later checkpoint may re-tag an already-tagged snapshot; the 
second call is a no-op.
+        tagger.tagUpTo(2L);
+        tagger.tagUpTo(2L);
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void testDropAbortedRemovesCreatedTag() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        tagger.add(1L);
+        commitSnapshot(table, 1L);
+        commitSnapshot(table, 2L);
+        tagger.tagUpTo(2L);
+        assertThat(table.tagManager().tagCount()).isEqualTo(1);
+
+        // A cumulative commit may have tagged an aborted savepoint; dropping 
it removes the tag.
+        tagger.dropAborted(1L);
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+    }
+
+    @Test
+    public void testDropAbortedWithoutTagIsNoop() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        commitSnapshot(table, 1L);
+
+        tagger.dropAborted(1L);
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+    }
+
+    @Test
+    public void testDropAbortedRemovesPendingBeforeTagging() throws Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        tagger.add(1L);
+        // Aborted before any tagging round, so the pending intent must not 
survive to tagUpTo.
+        tagger.dropAborted(1L);
+        commitSnapshot(table, 1L);
+        commitSnapshot(table, 2L);
+
+        tagger.tagUpTo(2L);
+
+        assertThat(table.tagManager().tagCount()).isEqualTo(0);
+    }
+
+    @Test
+    public void testDropAbortedKeepsTagFromDifferentCommitUser() throws 
Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        createSavepointTag(table, UUID.randomUUID().toString(), 1L, 1L);
+
+        tagger.dropAborted(1L);
+
+        assertThat(table.tagManager().tagExists(savepointTag(1L))).isTrue();
+    }
+
+    @Test
+    public void testDropAbortedKeepsTagForDifferentCommitIdentifier() throws 
Exception {
+        FileStoreTable table = createUnawareBucketTable();
+        SavepointTagger tagger = createTagger(table);
+
+        createSavepointTag(table, commitUser, 1L, 2L);
+
+        tagger.dropAborted(1L);
+
+        assertThat(table.tagManager().tagExists(savepointTag(1L))).isTrue();
+    }
+
+    private SavepointTagger createTagger(FileStoreTable table) {
+        return new SavepointTagger(
+                table.snapshotManager(),
+                table.tagManager(),
+                table.store().newTagDeletion(),
+                table.store().createTagCallbacks(table),
+                table.coreOptions().tagDefaultTimeRetained(),
+                commitUser);
+    }
+
+    private void commitSnapshot(FileStoreTable table, long commitIdentifier) 
throws Exception {
+        commitSnapshot(table, commitUser, commitIdentifier);
+    }
+
+    private void createSavepointTag(
+            FileStoreTable table,
+            String commitUser,
+            long commitIdentifier,
+            long snapshotCommitIdentifier)
+            throws Exception {
+        commitSnapshot(table, commitUser, snapshotCommitIdentifier);
+        table.tagManager()
+                .createTag(
+                        table.snapshotManager().latestSnapshot(),
+                        savepointTag(commitIdentifier),
+                        table.coreOptions().tagDefaultTimeRetained(),
+                        table.store().createTagCallbacks(table),
+                        false);
+    }
+
+    private void commitSnapshot(FileStoreTable table, String commitUser, long 
commitIdentifier)
+            throws Exception {
+        try (StreamTableWrite write =
+                        
table.newStreamWriteBuilder().withCommitUser(commitUser).newWrite();
+                StreamTableCommit commit =
+                        
table.newStreamWriteBuilder().withCommitUser(commitUser).newCommit()) {
+            write.write(GenericRow.of((int) commitIdentifier, 
commitIdentifier));
+            List<CommitMessage> messages = write.prepareCommit(false, 
commitIdentifier);
+            commit.commit(commitIdentifier, messages);
+        }
+    }
+
+    private FileStoreTable createUnawareBucketTable() throws Exception {
+        return createFileStoreTable(
+                options -> {
+                    options.set(CoreOptions.BUCKET, -1);
+                    options.remove("bucket-key");
+                });
+    }
+
+    private static String savepointTag(long checkpointId) {
+        return SavepointTagUtils.tagNameOf(checkpointId);
+    }
+}

Reply via email to