This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 370428b0 feat(datafusion): support replace_tag (#1214)
370428b0 is described below

commit 370428b053da5692fad3cf9db32f95e62d72ab98
Author: QuakeWang <[email protected]>
AuthorDate: Sun Oct 25 23:09:55 2026 +0800

    feat(datafusion): support replace_tag (#1214)
---
 Cargo.lock                                         |   1 +
 crates/integrations/datafusion/Cargo.toml          |   1 +
 crates/integrations/datafusion/src/procedures.rs   |  72 +++
 .../integrations/datafusion/tests/replace_tag.rs   | 627 +++++++++++++++++++++
 .../datafusion/tests/replace_tag/support.rs        | 258 +++++++++
 .../datafusion/tests/rest_management_procedures.rs |  30 +
 crates/paimon/src/table/tag_manager.rs             | 180 ++++++
 docs/src/sql.md                                    |  41 ++
 8 files changed, 1210 insertions(+)

diff --git a/Cargo.lock b/Cargo.lock
index 3b57ab16..046a85f8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4770,6 +4770,7 @@ dependencies = [
  "flate2",
  "futures",
  "log",
+ "opendal-core",
  "paimon",
  "paimon-ftindex-core",
  "parquet",
diff --git a/crates/integrations/datafusion/Cargo.toml 
b/crates/integrations/datafusion/Cargo.toml
index b3b33a99..09df5bb3 100644
--- a/crates/integrations/datafusion/Cargo.toml
+++ b/crates/integrations/datafusion/Cargo.toml
@@ -53,6 +53,7 @@ arrow-schema = { workspace = true }
 axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
 bytes = "1.7.1"
 flate2 = "1"
+opendal = { package = "opendal-core", version = "0.58.0", features = 
["services-memory"] }
 paimon-ftindex-core = "0.1.0"
 parquet = { workspace = true }
 tar = "0.4"
diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index 385fda0c..6b1d0b28 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -19,6 +19,7 @@
 //!
 //! Supported procedures:
 //! - `CALL sys.create_tag(table => '...', tag => '...', snapshot_id => ..., 
time_retained => '...')`
+//! - `CALL sys.replace_tag(table => '...', tag => '...', snapshot_id => ..., 
time_retained => '...')`
 //! - `CALL sys.delete_tag(table => '...', tag => '...')`
 //! - `CALL sys.rename_tag(table => '...', tag => '...', target_tag => '...')`
 //! - `CALL sys.reset_consumer(table => '...', consumer_id => '...'[, 
next_snapshot_id => ...])`
@@ -204,6 +205,7 @@ async fn earlier_or_equal_from_all(
 fn declared_parameters(proc_name: &str) -> Option<&'static [&'static str]> {
     Some(match proc_name {
         "create_tag" => &["table", "tag", "snapshot_id", "time_retained"],
+        "replace_tag" => &["table", "tag", "snapshot_id", "time_retained"],
         "delete_tag" => &["table", "tag"],
         "rename_tag" => &["table", "tag", "target_tag"],
         "reset_consumer" => &["table", "consumer_id", "next_snapshot_id"],
@@ -326,6 +328,7 @@ pub async fn execute_procedure(
 
     match proc_name {
         "create_tag" => proc_create_tag(ctx, catalog, catalog_name, 
&args).await,
+        "replace_tag" => proc_replace_tag(ctx, catalog, catalog_name, 
&args).await,
         "delete_tag" => proc_delete_tag(ctx, catalog, catalog_name, 
&args).await,
         "rename_tag" => proc_rename_tag(ctx, catalog, catalog_name, 
&args).await,
         "reset_consumer" => proc_reset_consumer(ctx, catalog, catalog_name, 
&args).await,
@@ -595,6 +598,75 @@ async fn proc_create_tag(
     ok_result(ctx)
 }
 
+async fn proc_replace_tag(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let table = get_table(catalog, catalog_name, args).await?;
+    let tag_name = require_arg(args, "tag")?;
+    let snapshot_id = args
+        .get("snapshot_id")
+        .map(|value| {
+            value
+                .parse::<i64>()
+                .ok()
+                .filter(|id| *id > 0)
+                .ok_or_else(|| {
+                    DataFusionError::Plan(format!(
+                        "Invalid snapshot_id: '{value}'. Expected a positive 
BIGINT"
+                    ))
+                })
+        })
+        .transpose()?;
+    let time_retained = args.get("time_retained").map(String::as_str);
+
+    if table.rest_env().is_some() {
+        return Err(DataFusionError::NotImplemented(
+            "replace_tag is not supported for tables managed by a REST 
catalog".to_string(),
+        ));
+    }
+    if table.schema().core_options().is_format_table() {
+        return Err(DataFusionError::NotImplemented(
+            "replace_tag requires a FileStoreTable; format-table is not 
supported".to_string(),
+        ));
+    }
+
+    let sm = table.snapshot_manager();
+    let tm = table.tag_manager();
+    if !tm.tag_exists(tag_name).await.map_err(to_datafusion_error)? {
+        return Err(to_datafusion_error(paimon::Error::TagNotExist {
+            tag_name: tag_name.to_string(),
+        }));
+    }
+    let snapshot = if let Some(id) = snapshot_id {
+        match sm.get_snapshot(id).await {
+            Ok(snapshot) => snapshot,
+            Err(paimon::Error::SnapshotNotExist { .. }) => tm
+                .list_all()
+                .await
+                .map_err(to_datafusion_error)?
+                .into_iter()
+                .map(|(_, snapshot)| snapshot)
+                .find(|snapshot| snapshot.id() == id)
+                .ok_or_else(|| {
+                    to_datafusion_error(paimon::Error::SnapshotNotExist { 
snapshot_id: id })
+                })?,
+            Err(error) => return Err(to_datafusion_error(error)),
+        }
+    } else {
+        sm.get_latest_snapshot()
+            .await
+            .map_err(to_datafusion_error)?
+            .ok_or_else(|| DataFusionError::Plan("No snapshots 
exist".to_string()))?
+    };
+    tm.replace(tag_name, &snapshot, time_retained)
+        .await
+        .map_err(to_datafusion_error)?;
+    ok_result(ctx)
+}
+
 async fn proc_rename_branch(
     ctx: &SessionContext,
     catalog: &Arc<dyn Catalog>,
diff --git a/crates/integrations/datafusion/tests/replace_tag.rs 
b/crates/integrations/datafusion/tests/replace_tag.rs
new file mode 100644
index 00000000..0cf5de04
--- /dev/null
+++ b/crates/integrations/datafusion/tests/replace_tag.rs
@@ -0,0 +1,627 @@
+// 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.
+
+mod common;
+#[path = "replace_tag/support.rs"]
+mod support;
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::Ordering;
+
+use common::{
+    assert_sql_error, collect_id_name, create_sql_context, create_test_env, 
exec, string_value,
+};
+use paimon::catalog::Identifier;
+use paimon::io::FileIOBuilder;
+use paimon::spec::{DataType, IntType, Schema};
+use paimon::table::{BranchManager, Table};
+use paimon::Catalog;
+use paimon_datafusion::SQLContext;
+
+async fn setup() -> (tempfile::TempDir, SQLContext, Table) {
+    let (tmp, catalog) = create_test_env();
+    let context = create_sql_context(catalog.clone()).await;
+    exec(&context, "CREATE SCHEMA paimon.test_db").await;
+    exec(
+        &context,
+        "CREATE TABLE paimon.test_db.events (id INT, name STRING) \
+         WITH ('tag.default-time-retained' = '7d')",
+    )
+    .await;
+    for (id, name) in [(1, "alice"), (2, "bob")] {
+        exec(
+            &context,
+            &format!("INSERT INTO paimon.test_db.events VALUES ({id}, 
'{name}')"),
+        )
+        .await;
+    }
+    let table = catalog
+        .get_table(&Identifier::new("test_db", "events"))
+        .await
+        .unwrap();
+    table
+        .tag_manager()
+        .create_with_retention(
+            "release",
+            &table.snapshot_manager().get_snapshot(1).await.unwrap(),
+            Some("1d"),
+        )
+        .await
+        .unwrap();
+    (tmp, context, table)
+}
+
+async fn assert_ok(context: &SQLContext, args: &str) {
+    let batches = context
+        .sql(&format!("CALL sys.replace_tag({args})"))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(batches.len(), 1);
+    assert_eq!(batches[0].num_rows(), 1);
+    assert_eq!(batches[0].num_columns(), 1);
+    assert_eq!(batches[0].schema().field(0).name(), "result");
+    assert_eq!(string_value(batches[0].column(0).as_ref(), 0), "OK");
+}
+
+fn table_files(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
+    let mut files = BTreeMap::new();
+    for entry in std::fs::read_dir(root).unwrap() {
+        let path = entry.unwrap().path();
+        if path.is_dir() {
+            files.extend(table_files(&path));
+        } else {
+            files.insert(path.clone(), std::fs::read(path).unwrap());
+        }
+    }
+    files
+}
+
+#[tokio::test]
+async fn replace_tag_updates_snapshots_retention_and_time_travel() {
+    let (tmp, context, table) = setup().await;
+    let root = tmp.path().join("test_db.db/events");
+    let tag_path = root.join("tag/tag-release");
+    let mut original_files = table_files(&root);
+    original_files.remove(&tag_path).unwrap();
+    let first = vec![(1, "alice".to_string())];
+    let latest = vec![(1, "alice".to_string()), (2, "bob".to_string())];
+    let tagged_query = "SELECT id, name FROM paimon.test_db.events VERSION AS 
OF 'release'";
+    assert_eq!(collect_id_name(&context, tagged_query).await, first);
+
+    for (args, snapshot_id, retention, expected_rows) in [
+        ("", 2, None, &latest),
+        (
+            ", snapshot_id => 1, time_retained => '2d'",
+            1,
+            Some("PT48H"),
+            &first,
+        ),
+        (
+            ", snapshot_id => 1, time_retained => '12h'",
+            1,
+            Some("PT12H"),
+            &first,
+        ),
+        (", snapshot_id => 1", 1, None, &first),
+    ] {
+        assert_ok(
+            &context,
+            &format!("table => 'paimon.test_db.events', tag => 
'release'{args}"),
+        )
+        .await;
+        let batches = context.sql("SELECT snapshot_id, create_time, 
time_retained FROM paimon.test_db.`events$tags` WHERE tag_name = 'release'")
+            .await.unwrap().collect().await.unwrap();
+        assert_eq!(
+            batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
+            1
+        );
+        let row = &batches[0];
+        let ids = row
+            .column(0)
+            .as_any()
+            .downcast_ref::<arrow_array::Int64Array>()
+            .unwrap();
+        assert_eq!(ids.value(0), snapshot_id);
+        assert_eq!(row.column(1).is_null(0), retention.is_none());
+        let ttl = row.column(2);
+        assert_eq!(
+            (!ttl.is_null(0)).then(|| string_value(ttl.as_ref(), 0)),
+            retention
+        );
+        let value: serde_json::Value =
+            
serde_json::from_slice(&std::fs::read(&tag_path).unwrap()).unwrap();
+        assert_eq!(value.get("tagCreateTime").is_some(), retention.is_some());
+        assert_eq!(value.get("tagTimeRetained").is_some(), 
retention.is_some());
+        assert_eq!(
+            &collect_id_name(&context, tagged_query).await,
+            expected_rows
+        );
+        assert_eq!(
+            collect_id_name(&context, "SELECT id, name FROM 
paimon.test_db.events").await,
+            latest
+        );
+        let mut actual_files = table_files(&root);
+        actual_files.remove(&tag_path).unwrap();
+        assert_eq!(actual_files, original_files);
+    }
+    assert_eq!(
+        table
+            .tag_manager()
+            .get("release")
+            .await
+            .unwrap()
+            .unwrap()
+            .id(),
+        1
+    );
+}
+
+#[tokio::test]
+async fn replace_tag_works_with_create_rename_and_delete() {
+    let (tmp, context, _table) = setup().await;
+    let root = tmp.path().join("test_db.db/events/tag");
+    let original = std::fs::read(root.join("tag-release")).unwrap();
+    exec(
+        &context,
+        "CALL sys.create_tag(table => 'test_db.events', tag => 'checkpoint', 
snapshot_id => 1)",
+    )
+    .await;
+    assert_ok(
+        &context,
+        "table => 'test_db.events', tag => 'checkpoint', time_retained => 
'2d'",
+    )
+    .await;
+    let replaced = std::fs::read(root.join("tag-checkpoint")).unwrap();
+    exec(
+        &context,
+        "CALL sys.rename_tag(table => 'test_db.events', tag => 'checkpoint', 
target_tag => 'checkpoint_v2')",
+    )
+    .await;
+    assert!(!root.join("tag-checkpoint").exists());
+    assert_eq!(
+        std::fs::read(root.join("tag-checkpoint_v2")).unwrap(),
+        replaced
+    );
+    assert_eq!(
+        collect_id_name(
+            &context,
+            "SELECT id, name FROM paimon.test_db.events VERSION AS OF 
'checkpoint_v2'"
+        )
+        .await,
+        vec![(1, "alice".to_string()), (2, "bob".to_string())]
+    );
+    exec(
+        &context,
+        "CALL sys.delete_tag(table => 'test_db.events', tag => 
'checkpoint_v2')",
+    )
+    .await;
+    assert!(!root.join("tag-checkpoint_v2").exists());
+    assert_eq!(std::fs::read(root.join("tag-release")).unwrap(), original);
+    assert_eq!(std::fs::read_dir(root).unwrap().count(), 1);
+}
+
+#[tokio::test]
+async fn replace_tag_resolves_retained_snapshots_including_its_own() {
+    let (tmp, context, table) = setup().await;
+    let snapshot = table.snapshot_manager().get_snapshot(1).await.unwrap();
+    table.tag_manager().create("keep", &snapshot).await.unwrap();
+    assert_ok(&context, "table => 'test_db.events', tag => 'release'").await;
+    
std::fs::remove_file(tmp.path().join("test_db.db/events/snapshot/snapshot-1")).unwrap();
+    assert_ok(
+        &context,
+        "table => 'test_db.events', tag => 'release', snapshot_id => 1",
+    )
+    .await;
+    table.tag_manager().delete("keep").await.unwrap();
+    assert_ok(
+        &context,
+        "table => 'test_db.events', tag => 'release', snapshot_id => 1, 
time_retained => '2d'",
+    )
+    .await;
+    assert_eq!(
+        collect_id_name(
+            &context,
+            "SELECT id, name FROM paimon.test_db.events VERSION AS OF 
'release'"
+        )
+        .await,
+        vec![(1, "alice".to_string())]
+    );
+}
+
+#[tokio::test]
+async fn replace_tag_rejects_invalid_arguments_without_writes() {
+    let (tmp, context, _table) = setup().await;
+    let root = tmp.path().join("test_db.db/events");
+    let original = table_files(&root);
+    for (args, message) in [
+        ("tag => 'release'", "Missing required argument: 'table'"),
+        (
+            "table => 'test_db.events'",
+            "Missing required argument: 'tag'",
+        ),
+        (
+            "table => 'test_db.events', tag => 'missing'",
+            "Tag missing does not exist",
+        ),
+        ("table => 'test_db.events', tag => ''", "Invalid tag name"),
+        (
+            "table => 'test_db.events', tag => '../release'",
+            "Invalid tag name",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release '",
+            "Invalid tag name",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => 
'abc'",
+            "Invalid snapshot_id",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => 1.5",
+            "Invalid snapshot_id",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => 0",
+            "Invalid snapshot_id",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => -1",
+            "Invalid snapshot_id",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => 
9223372036854775808",
+            "Invalid snapshot_id",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => 
-9223372036854775809",
+            "Invalid snapshot_id",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => 42",
+            "Snapshot 42 does not exist",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot => 1",
+            "Argument snapshot is unknown",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', time_retain => '1d'",
+            "Argument time_retain is unknown",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', snapshot_id => 1, 
snapshot_id => 2",
+            "Procedure argument snapshot_id is duplicated",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', tag => 'other'",
+            "Procedure argument tag is duplicated",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', time_retained => ''",
+            "Invalid tag retention",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', time_retained => 
'-1d'",
+            "Invalid tag retention",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', time_retained => 
'invalid'",
+            "Invalid tag retention",
+        ),
+        (
+            "table => 'test_db.events', tag => 'release', time_retained => 
'106751991167301d'",
+            "Invalid tag retention",
+        ),
+    ] {
+        assert_sql_error(&context, &format!("CALL sys.replace_tag({args})"), 
message).await;
+        assert_eq!(table_files(&root), original, "{args}");
+    }
+}
+
+#[tokio::test]
+async fn replace_tag_requires_a_live_latest_snapshot_when_id_is_omitted() {
+    let (tmp, context, _table) = setup().await;
+    let root = tmp.path().join("test_db.db/events");
+    for id in [1, 2] {
+        
std::fs::remove_file(root.join(format!("snapshot/snapshot-{id}"))).unwrap();
+    }
+    for (remove_hint, message) in [
+        (false, "Snapshot 2 does not exist"),
+        (true, "No snapshots exist"),
+    ] {
+        if remove_hint {
+            std::fs::remove_file(root.join("snapshot/LATEST")).unwrap();
+        }
+        let original = table_files(&root);
+        assert_sql_error(
+            &context,
+            "CALL sys.replace_tag(table => 'test_db.events', tag => 
'release')",
+            message,
+        )
+        .await;
+        assert_eq!(table_files(&root), original);
+    }
+    assert_ok(
+        &context,
+        "table => 'test_db.events', tag => 'release', snapshot_id => 1",
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn replace_tag_propagates_invalid_live_snapshot_instead_of_using_a_tag() 
{
+    let (tmp, context, table) = setup().await;
+    let root = tmp.path().join("test_db.db/events");
+    let snapshot = table.snapshot_manager().get_snapshot(1).await.unwrap();
+    for (content, message) in [
+        (b"{".to_vec(), "snapshot JSON invalid"),
+        (
+            
serde_json::to_vec(&table.snapshot_manager().get_snapshot(2).await.unwrap()).unwrap(),
+            "snapshot file id mismatch",
+        ),
+    ] {
+        std::fs::write(root.join("snapshot/snapshot-1"), content).unwrap();
+        let original = table_files(&root);
+        assert_sql_error(
+            &context,
+            "CALL sys.replace_tag(table => 'test_db.events', tag => 'release', 
snapshot_id => 1)",
+            message,
+        )
+        .await;
+        assert_eq!(table_files(&root), original);
+        assert_eq!(
+            table.tag_manager().get("release").await.unwrap().unwrap(),
+            snapshot
+        );
+    }
+}
+
+#[tokio::test]
+async fn replace_tag_preserves_other_branches_and_existing_name_resolution() {
+    let (tmp, context, table) = setup().await;
+    BranchManager::new(table.file_io().clone(), table.location().to_string())
+        .create_branch_from_tag("dev", "release")
+        .await
+        .unwrap();
+    let branch = table.copy_with_branch("dev").await.unwrap();
+    branch
+        .tag_manager()
+        .create(
+            "release",
+            &table.snapshot_manager().get_snapshot(1).await.unwrap(),
+        )
+        .await
+        .unwrap();
+    let root = tmp.path().join("test_db.db/events");
+    let original_branch = table_files(&root.join("branch"));
+    assert_ok(&context, "table => 'test_db.events', tag => 'release'").await;
+    assert_eq!(table_files(&root.join("branch")), original_branch);
+    let original = table_files(&root);
+    assert_sql_error(
+        &context,
+        "CALL sys.replace_tag(table => 'test_db.events$branch_dev', tag => 
'release')",
+        "does not exist",
+    )
+    .await;
+    assert_eq!(table_files(&root), original);
+}
+
+#[tokio::test]
+async fn replace_tag_uses_the_resolved_branch_for_snapshots_and_tags() {
+    let (tmp, _context, table) = setup().await;
+    BranchManager::new(table.file_io().clone(), table.location().to_string())
+        .create_branch_from_tag("dev", "release")
+        .await
+        .unwrap();
+    let branch = table.copy_with_branch("dev").await.unwrap();
+    assert_eq!(branch.branch(), "dev");
+
+    // Distinct metadata for the same ID also catches lookups in the wrong 
namespace.
+    let mut value =
+        
serde_json::to_value(table.snapshot_manager().get_snapshot(1).await.unwrap()).unwrap();
+    value["commitUser"] = serde_json::json!("dev-live");
+    let live: paimon::spec::Snapshot = 
serde_json::from_value(value.clone()).unwrap();
+    branch
+        .file_io()
+        .new_output(&branch.snapshot_manager().snapshot_path(1))
+        .unwrap()
+        .write(serde_json::to_vec(&live).unwrap().into())
+        .await
+        .unwrap();
+    branch
+        .tag_manager()
+        .create_with_retention("release", &live, Some("1d"))
+        .await
+        .unwrap();
+    value["id"] = serde_json::json!(3);
+    value["commitUser"] = serde_json::json!("dev-retained");
+    let retained: paimon::spec::Snapshot = 
serde_json::from_value(value.clone()).unwrap();
+    branch
+        .tag_manager()
+        .create("keep", &retained)
+        .await
+        .unwrap();
+    value["commitUser"] = serde_json::json!("main-retained");
+    table
+        .tag_manager()
+        .create("keep", &serde_json::from_value(value.clone()).unwrap())
+        .await
+        .unwrap();
+    value["id"] = serde_json::json!(4);
+    table
+        .tag_manager()
+        .create("main_only", &serde_json::from_value(value).unwrap())
+        .await
+        .unwrap();
+
+    let root = tmp.path().join("test_db.db/events");
+    let branch_root = root.join("branch/branch-dev");
+    let mut original_main = table_files(&root);
+    original_main.retain(|path, _| !path.starts_with(&branch_root));
+    let context = support::context_with_table(tmp.path(), 
branch.clone()).await;
+
+    for (args, expected) in [
+        ("", &live),
+        (", snapshot_id => 1", &live),
+        (", snapshot_id => 3", &retained),
+    ] {
+        assert_ok(
+            &context,
+            &format!("table => 'test_db.events', tag => 'release'{args}"),
+        )
+        .await;
+        assert_eq!(
+            branch.tag_manager().get("release").await.unwrap().as_ref(),
+            Some(expected)
+        );
+        let mut current_main = table_files(&root);
+        current_main.retain(|path, _| !path.starts_with(&branch_root));
+        assert_eq!(current_main, original_main);
+    }
+
+    // Neither a main-only live snapshot nor a main-only retained snapshot may 
resolve.
+    for id in [2, 4] {
+        let original = table_files(&root);
+        assert_sql_error(
+            &context,
+            &format!("CALL sys.replace_tag(table => 'test_db.events', tag => 
'release', snapshot_id => {id})"),
+            &format!("Snapshot {id} does not exist"),
+        ).await;
+        assert_eq!(table_files(&root), original);
+    }
+}
+
+#[tokio::test]
+async fn replace_tag_propagates_writer_creation_failure_through_core_and_sql() 
{
+    let (tmp, _context, table) = setup().await;
+    let snapshot = table.snapshot_manager().get_snapshot(1).await.unwrap();
+    let operator = 
opendal::Operator::new(opendal::services::Memory::default()).unwrap();
+    let file_io = FileIOBuilder::new("fs")
+        .with_fs_operator(operator.clone())
+        .build()
+        .unwrap();
+    let clean = Table::from_resolved_schema(
+        file_io,
+        table.identifier().clone(),
+        "file:/write_failure".to_string(),
+        table.schema().clone(),
+        "main",
+    )
+    .unwrap();
+    clean
+        .snapshot_manager()
+        .commit_snapshot(&snapshot)
+        .await
+        .unwrap();
+    clean
+        .tag_manager()
+        .create("release", &snapshot)
+        .await
+        .unwrap();
+
+    let (operator, attempts) = support::fail_tag_writes(operator);
+    let file_io = FileIOBuilder::new("fs")
+        .with_fs_operator(operator)
+        .build()
+        .unwrap();
+    let failing = Table::from_resolved_schema(
+        file_io,
+        clean.identifier().clone(),
+        clean.location().to_string(),
+        clean.schema().clone(),
+        "main",
+    )
+    .unwrap();
+    assert_eq!(
+        failing.tag_manager().get("release").await.unwrap(),
+        Some(snapshot.clone())
+    );
+    assert_eq!(
+        failing.snapshot_manager().get_snapshot(1).await.unwrap(),
+        snapshot
+    );
+    assert_eq!(attempts.load(Ordering::SeqCst), 0);
+
+    let error = failing
+        .tag_manager()
+        .replace("release", &snapshot, Some("2d"))
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(&error, paimon::Error::IoUnexpected { source, .. } if 
source.kind() == opendal::ErrorKind::Unexpected)
+    );
+    assert!(
+        error.to_string().contains(support::WRITE_FAILURE),
+        "{error}"
+    );
+    assert_eq!(attempts.load(Ordering::SeqCst), 1);
+
+    let context = support::context_with_table(tmp.path(), failing).await;
+    assert_sql_error(
+        &context,
+        "CALL sys.replace_tag(table => 'test_db.events', tag => 'release', 
snapshot_id => 1, time_retained => '2d')",
+        support::WRITE_FAILURE,
+    ).await;
+    assert_eq!(attempts.load(Ordering::SeqCst), 2);
+}
+
+#[tokio::test]
+async fn replace_tag_rejects_unsupported_table_types_before_reading_metadata() 
{
+    for table_type in [
+        "format-table",
+        "object-table",
+        "iceberg-table",
+        "lance-table",
+    ] {
+        let (tmp, catalog) = create_test_env();
+        catalog
+            .create_database("test_db", false, Default::default())
+            .await
+            .unwrap();
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("type", table_type)
+            .option("file.format", "parquet")
+            .build()
+            .unwrap();
+        catalog
+            .create_table(&Identifier::new("test_db", "events"), schema, false)
+            .await
+            .unwrap();
+        let root = tmp.path().join("test_db.db/events");
+        std::fs::create_dir(root.join("tag")).unwrap();
+        std::fs::write(root.join("tag/tag-release"), "{").unwrap();
+        let original = table_files(&root);
+        let context = create_sql_context(catalog).await;
+        let message = if table_type == "format-table" {
+            "requires a FileStoreTable"
+        } else {
+            "cannot be read as a Paimon table"
+        };
+        assert_sql_error(
+            &context,
+            "CALL sys.replace_tag(table => 'test_db.events', tag => 'release', 
snapshot_id => 1)",
+            message,
+        )
+        .await;
+        assert_eq!(table_files(&root), original);
+    }
+}
diff --git a/crates/integrations/datafusion/tests/replace_tag/support.rs 
b/crates/integrations/datafusion/tests/replace_tag/support.rs
new file mode 100644
index 00000000..5691e9af
--- /dev/null
+++ b/crates/integrations/datafusion/tests/replace_tag/support.rs
@@ -0,0 +1,258 @@
+// 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.
+
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use opendal::raw::*;
+use opendal::{Capability, Operator};
+use paimon::catalog::{Database, Identifier};
+use paimon::spec::{Schema, SchemaChange};
+use paimon::table::Table;
+use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
+use paimon_datafusion::SQLContext;
+
+pub async fn context_with_table(warehouse: &Path, table: Table) -> SQLContext {
+    let mut options = Options::new();
+    options.set(
+        CatalogOptions::WAREHOUSE,
+        format!("file://{}", warehouse.display()),
+    );
+    let catalog = ResolvedTableCatalog {
+        inner: FileSystemCatalog::new(options).unwrap(),
+        table,
+    };
+    let mut context = SQLContext::new();
+    context
+        .register_catalog_with_default_db("paimon", Arc::new(catalog), None)
+        .await
+        .unwrap();
+    context
+}
+
+struct ResolvedTableCatalog {
+    inner: FileSystemCatalog,
+    table: Table,
+}
+
+#[async_trait]
+impl Catalog for ResolvedTableCatalog {
+    async fn list_databases(&self) -> paimon::Result<Vec<String>> {
+        self.inner.list_databases().await
+    }
+
+    async fn create_database(
+        &self,
+        name: &str,
+        ignore_if_exists: bool,
+        properties: HashMap<String, String>,
+    ) -> paimon::Result<()> {
+        self.inner
+            .create_database(name, ignore_if_exists, properties)
+            .await
+    }
+
+    async fn get_database(&self, name: &str) -> paimon::Result<Database> {
+        self.inner.get_database(name).await
+    }
+
+    async fn drop_database(
+        &self,
+        name: &str,
+        ignore_if_not_exists: bool,
+        cascade: bool,
+    ) -> paimon::Result<()> {
+        self.inner
+            .drop_database(name, ignore_if_not_exists, cascade)
+            .await
+    }
+
+    async fn get_table(&self, identifier: &Identifier) -> 
paimon::Result<Table> {
+        assert_eq!(identifier, self.table.identifier());
+        Ok(self.table.clone())
+    }
+
+    async fn list_tables(&self, database_name: &str) -> 
paimon::Result<Vec<String>> {
+        self.inner.list_tables(database_name).await
+    }
+
+    async fn create_table(
+        &self,
+        identifier: &Identifier,
+        creation: Schema,
+        ignore_if_exists: bool,
+    ) -> paimon::Result<()> {
+        self.inner
+            .create_table(identifier, creation, ignore_if_exists)
+            .await
+    }
+
+    async fn drop_table(
+        &self,
+        identifier: &Identifier,
+        ignore_if_not_exists: bool,
+    ) -> paimon::Result<()> {
+        self.inner
+            .drop_table(identifier, ignore_if_not_exists)
+            .await
+    }
+
+    async fn rename_table(
+        &self,
+        from: &Identifier,
+        to: &Identifier,
+        ignore_if_not_exists: bool,
+    ) -> paimon::Result<()> {
+        self.inner
+            .rename_table(from, to, ignore_if_not_exists)
+            .await
+    }
+
+    async fn alter_table(
+        &self,
+        identifier: &Identifier,
+        changes: Vec<SchemaChange>,
+        ignore_if_not_exists: bool,
+    ) -> paimon::Result<()> {
+        self.inner
+            .alter_table(identifier, changes, ignore_if_not_exists)
+            .await
+    }
+}
+
+pub const WRITE_FAILURE: &str = "injected tag writer creation failure";
+
+pub fn fail_tag_writes(operator: Operator) -> (Operator, Arc<AtomicUsize>) {
+    let attempts = Arc::new(AtomicUsize::new(0));
+    let (context, inner) = operator.into_parts();
+    let service = FailTagWrites {
+        inner,
+        attempts: attempts.clone(),
+    };
+    (Operator::from_parts(context, Arc::new(service)), attempts)
+}
+
+#[derive(Debug)]
+struct FailTagWrites {
+    inner: Servicer,
+    attempts: Arc<AtomicUsize>,
+}
+
+impl Service for FailTagWrites {
+    type Reader = oio::Reader;
+    type Writer = oio::Writer;
+    type Lister = oio::Lister;
+    type Deleter = oio::Deleter;
+    type Copier = oio::Copier;
+
+    fn info(&self) -> ServiceInfo {
+        self.inner.info()
+    }
+
+    fn capability(&self) -> Capability {
+        self.inner.capability()
+    }
+
+    async fn create_dir(
+        &self,
+        ctx: &opendal::OperationContext,
+        path: &str,
+        args: OpCreateDir,
+    ) -> opendal::Result<RpCreateDir> {
+        self.inner.create_dir(ctx, path, args).await
+    }
+
+    async fn stat(
+        &self,
+        ctx: &opendal::OperationContext,
+        path: &str,
+        args: OpStat,
+    ) -> opendal::Result<RpStat> {
+        self.inner.stat(ctx, path, args).await
+    }
+
+    fn read(
+        &self,
+        ctx: &opendal::OperationContext,
+        path: &str,
+        args: OpRead,
+    ) -> opendal::Result<Self::Reader> {
+        self.inner.read(ctx, path, args)
+    }
+
+    fn write(
+        &self,
+        ctx: &opendal::OperationContext,
+        path: &str,
+        args: OpWrite,
+    ) -> opendal::Result<Self::Writer> {
+        if path.ends_with("/tag/tag-release") {
+            self.attempts.fetch_add(1, Ordering::SeqCst);
+            return Err(opendal::Error::new(
+                opendal::ErrorKind::Unexpected,
+                WRITE_FAILURE,
+            ));
+        }
+        self.inner.write(ctx, path, args)
+    }
+
+    fn delete(&self, ctx: &opendal::OperationContext) -> 
opendal::Result<Self::Deleter> {
+        self.inner.delete(ctx)
+    }
+
+    fn list(
+        &self,
+        ctx: &opendal::OperationContext,
+        path: &str,
+        args: OpList,
+    ) -> opendal::Result<Self::Lister> {
+        self.inner.list(ctx, path, args)
+    }
+
+    fn copy(
+        &self,
+        ctx: &opendal::OperationContext,
+        from: &str,
+        to: &str,
+        args: OpCopy,
+        opts: OpCopier,
+    ) -> opendal::Result<Self::Copier> {
+        self.inner.copy(ctx, from, to, args, opts)
+    }
+
+    async fn rename(
+        &self,
+        ctx: &opendal::OperationContext,
+        from: &str,
+        to: &str,
+        args: OpRename,
+    ) -> opendal::Result<RpRename> {
+        self.inner.rename(ctx, from, to, args).await
+    }
+
+    async fn presign(
+        &self,
+        ctx: &opendal::OperationContext,
+        path: &str,
+        args: OpPresign,
+    ) -> opendal::Result<RpPresign> {
+        self.inner.presign(ctx, path, args).await
+    }
+}
diff --git a/crates/integrations/datafusion/tests/rest_management_procedures.rs 
b/crates/integrations/datafusion/tests/rest_management_procedures.rs
index 22568abd..486e4c8b 100644
--- a/crates/integrations/datafusion/tests/rest_management_procedures.rs
+++ b/crates/integrations/datafusion/tests/rest_management_procedures.rs
@@ -684,6 +684,36 @@ async fn 
test_consumer_procedures_rejected_on_rest_catalog_without_changing_prog
     assert_eq!(std::fs::read(&consumer_path).unwrap(), original);
 }
 
+#[tokio::test]
+async fn test_replace_tag_rejected_on_rest_catalog_without_changing_metadata() 
{
+    let (tmp, server, context) = setup().await;
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .build()
+        .unwrap();
+    let path = format!("file://{}/{DATABASE}.db/{TABLE}", 
tmp.path().display());
+    server.add_table_with_schema(DATABASE, TABLE, schema, &path);
+    let tag_dir = tmp.path().join(format!("{DATABASE}.db/{TABLE}/tag"));
+    std::fs::create_dir_all(&tag_dir).unwrap();
+    let tag_path = tag_dir.join("tag-release");
+    // Invalid metadata must remain unread: REST rejection precedes storage 
access.
+    let original = b"{";
+    std::fs::write(&tag_path, original).unwrap();
+
+    for snapshot in ["", ", snapshot_id => 1"] {
+        common::assert_sql_error(
+            &context,
+            &format!(
+                "CALL sys.replace_tag(table => '{DATABASE}.{TABLE}', tag => 
'release'{snapshot})"
+            ),
+            "replace_tag is not supported for tables managed by a REST 
catalog",
+        )
+        .await;
+        assert_eq!(std::fs::read(&tag_path).unwrap(), original);
+        assert_eq!(std::fs::read_dir(&tag_dir).unwrap().count(), 1);
+    }
+}
+
 #[tokio::test]
 async fn test_rename_branch_rejected_on_rest_catalog() {
     // A REST catalog is authoritative for branch metadata and has no rename
diff --git a/crates/paimon/src/table/tag_manager.rs 
b/crates/paimon/src/table/tag_manager.rs
index 19baefed..a9dce5db 100644
--- a/crates/paimon/src/table/tag_manager.rs
+++ b/crates/paimon/src/table/tag_manager.rs
@@ -262,6 +262,26 @@ impl TagManager {
         output.write(bytes::Bytes::from(json)).await
     }
 
+    /// Replace an existing tag's snapshot and optional retention.
+    ///
+    /// An explicit retention resets the creation time. `None` removes both 
TTL fields.
+    /// Validation completes before writing. The existence check and overwrite 
are not
+    /// atomic; concurrent tag changes and rollback after storage failures are 
not supported.
+    pub async fn replace(
+        &self,
+        tag_name: &str,
+        snapshot: &Snapshot,
+        time_retained: Option<&str>,
+    ) -> crate::Result<()> {
+        if !self.tag_exists(tag_name).await? {
+            return Err(crate::Error::TagNotExist {
+                tag_name: tag_name.to_string(),
+            });
+        }
+        self.create_with_retention(tag_name, snapshot, time_retained)
+            .await
+    }
+
     /// Delete a tag file.
     pub async fn delete(&self, tag_name: &str) -> crate::Result<()> {
         validate_tag_name(tag_name)?;
@@ -452,6 +472,166 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn test_replace_tag_changes_snapshot_and_requires_existing_tag() {
+        let file_io = test_file_io();
+        let tm = TagManager::new(file_io.clone(), 
"memory:/test_tag_replace".to_string());
+        assert!(matches!(
+            tm.replace("missing", &test_snapshot(2), None).await,
+            Err(crate::Error::TagNotExist { tag_name }) if tag_name == 
"missing"
+        ));
+        assert!(!file_io.exists(&tm.tag_directory()).await.unwrap());
+
+        tm.create("release", &test_snapshot(1)).await.unwrap();
+        tm.replace("release", &test_snapshot(2), None)
+            .await
+            .unwrap();
+        assert_eq!(tm.get("release").await.unwrap(), Some(test_snapshot(2)));
+        assert_eq!(tm.list_all_names().await.unwrap(), vec!["release"]);
+    }
+
+    #[tokio::test]
+    async fn 
test_replace_tag_resets_retention_metadata_and_clears_omitted_ttl() {
+        let file_io = test_file_io();
+        let tm = TagManager::new(file_io.clone(), 
"memory:/test_tag_replace_ttl".to_string());
+        let snapshot = test_snapshot(1);
+        let path = tm.tag_path("release");
+        tm.create("release", &snapshot).await.unwrap();
+        for (retention, seconds) in [("1d", "86400.000000000"), ("2d", 
"172800.000000000")] {
+            if retention == "2d" {
+                // Seed a known old time so the test does not depend on clock 
resolution.
+                let bytes = 
file_io.new_input(&path).unwrap().read().await.unwrap();
+                let mut value: serde_json::Value = 
serde_json::from_slice(&bytes).unwrap();
+                value[FIELD_TAG_CREATE_TIME] = serde_json::json!([2000, 1, 1, 
0, 0, 0, 0]);
+                file_io
+                    .new_output(&path)
+                    .unwrap()
+                    .write(Bytes::from(serde_json::to_vec(&value).unwrap()))
+                    .await
+                    .unwrap();
+            }
+            let before = chrono::Local::now().naive_local();
+            tm.replace("release", &snapshot, Some(retention))
+                .await
+                .unwrap();
+            let after = chrono::Local::now().naive_local();
+            let bytes = 
file_io.new_input(&path).unwrap().read().await.unwrap();
+            let value: serde_json::Value = 
serde_json::from_slice(&bytes).unwrap();
+            assert_eq!(
+                value[FIELD_TAG_TIME_RETAINED]
+                    .as_number()
+                    .unwrap()
+                    .to_string(),
+                seconds
+            );
+            let created = 
parse_tag_create_time(&value[FIELD_TAG_CREATE_TIME]).unwrap();
+            assert!(before <= created && created <= after);
+            assert_eq!(serde_json::from_value::<Snapshot>(value).unwrap(), 
snapshot);
+        }
+        tm.replace("release", &snapshot, None).await.unwrap();
+        let bytes = file_io.new_input(&path).unwrap().read().await.unwrap();
+        assert_eq!(bytes.as_ref(), serde_json::to_vec(&snapshot).unwrap());
+        let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
+        assert!(value.get(FIELD_TAG_CREATE_TIME).is_none());
+        assert!(value.get(FIELD_TAG_TIME_RETAINED).is_none());
+    }
+
+    #[tokio::test]
+    async fn test_replace_tag_validation_preserves_original_bytes() {
+        let file_io = test_file_io();
+        let tm = TagManager::new(
+            file_io.clone(),
+            "memory:/test_tag_replace_invalid".to_string(),
+        );
+        tm.create_with_retention("release", &test_snapshot(1), Some("1d"))
+            .await
+            .unwrap();
+        let path = tm.tag_path("release");
+        let original = file_io.new_input(&path).unwrap().read().await.unwrap();
+        for name in ["", " ", "release ", "../tag-release", "a/b", "a\\b", 
"a\nb"] {
+            assert!(matches!(
+                tm.replace(name, &test_snapshot(2), None).await,
+                Err(crate::Error::ConfigInvalid { .. })
+            ));
+            assert_eq!(
+                file_io.new_input(&path).unwrap().read().await.unwrap(),
+                original
+            );
+        }
+        for retention in [
+            "",
+            "bad",
+            "-1d",
+            "1.5h",
+            "PT24H",
+            "9223372036854775808ns",
+            "106751991167301d",
+        ] {
+            assert!(matches!(
+                tm.replace("release", &test_snapshot(2), Some(retention))
+                    .await,
+                Err(crate::Error::ConfigInvalid { .. })
+            ));
+            assert_eq!(
+                file_io.new_input(&path).unwrap().read().await.unwrap(),
+                original
+            );
+        }
+        assert_eq!(tm.list_all_names().await.unwrap(), vec!["release"]);
+    }
+
+    #[tokio::test]
+    async fn test_replace_tag_keeps_branch_managers_isolated() {
+        let file_io = test_file_io();
+        let main = TagManager::new(
+            file_io.clone(),
+            "memory:/test_tag_replace_branch".to_string(),
+        );
+        let branch = main.with_branch("dev");
+        main.create("release", &test_snapshot(1)).await.unwrap();
+        branch.create("release", &test_snapshot(10)).await.unwrap();
+        let main_path = main.tag_path("release");
+        let original_main = 
file_io.new_input(&main_path).unwrap().read().await.unwrap();
+        branch
+            .replace("release", &test_snapshot(20), Some("2d"))
+            .await
+            .unwrap();
+        assert_eq!(
+            file_io.new_input(&main_path).unwrap().read().await.unwrap(),
+            original_main
+        );
+        assert_eq!(
+            branch.get("release").await.unwrap(),
+            Some(test_snapshot(20))
+        );
+        let branch_path = branch.tag_path("release");
+        let original_branch = file_io
+            .new_input(&branch_path)
+            .unwrap()
+            .read()
+            .await
+            .unwrap();
+        main.replace("release", &test_snapshot(2), None)
+            .await
+            .unwrap();
+        assert_eq!(
+            file_io
+                .new_input(&branch_path)
+                .unwrap()
+                .read()
+                .await
+                .unwrap(),
+            original_branch
+        );
+        assert_eq!(main.get("release").await.unwrap(), Some(test_snapshot(2)));
+        main.create("main_only", &test_snapshot(2)).await.unwrap();
+        assert!(matches!(
+            branch.replace("main_only", &test_snapshot(20), None).await,
+            Err(crate::Error::TagNotExist { .. })
+        ));
+        assert!(!branch.tag_exists("main_only").await.unwrap());
+    }
+
     #[tokio::test]
     async fn test_rename_tag_preserves_metadata() {
         let tm = TagManager::new(test_file_io(), 
"memory:/test_tag_rename".to_string());
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 82553172..0e1ebea7 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1180,6 +1180,47 @@ the tag file. For example, `'1d'` appears as `PT24H` in 
the `time_retained` colu
 of `$tags`. Omitting `time_retained` creates a tag without these metadata 
fields.
 This procedure does not automatically delete expired tags.
 
+### replace_tag
+
+Replace an existing manual tag's snapshot and retention without changing its 
name:
+
+```sql
+CALL sys.replace_tag(
+    table => 'paimon.my_db.my_table',
+    tag => 'release',
+    snapshot_id => 1,
+    time_retained => '2d'
+);
+```
+
+`table` and `tag` are required. The tag must already exist. `snapshot_id` is an
+optional positive BIGINT; omitting it selects the latest live snapshot when the
+operation resolves its target. An explicit ID first resolves a live snapshot,
+then falls back to snapshots preserved by tags in the same branch if the live
+snapshot does not exist. Other snapshot read errors are returned. No suitable
+snapshot causes an error without changing the tag. `snapshot` is not an alias
+for `snapshot_id`. Success returns one row with `result = 'OK'`.
+
+`time_retained` uses the same duration format as `create_tag`. Providing it
+replaces the TTL and resets `tagCreateTime` to the replacement time, even when
+the snapshot ID stays the same. **Omitting it removes the previous TTL and
+creation time**: the tag is written as plain Snapshot JSON. It does not inherit
+the old TTL or apply a table's default tag retention. Argument, name, 
retention,
+and snapshot resolution errors leave the original tag file unchanged.
+
+Only non-REST FileStore tables and ordinary manual tags are supported. Format
+tables and engine-served table types are rejected. This procedure does not add
+branch-qualified SQL name resolution; an already resolved branch Table uses its
+own snapshot and tag managers. Automatic tag management and external tag
+callbacks are outside its scope.
+
+Replacement overwrites the tag metadata directly. It does not create a snapshot
+or clean up old snapshots, manifests, or data files, and it does not run tag
+expiration. Storage write failures are returned, but atomic replacement and
+rollback are not guaranteed across storage backends. The existence check and
+write are separate operations; concurrent tag modifications are not protected
+by a conditional update or transaction.
+
 ### create_tag_from_timestamp
 
 Create a named tag from a commit timestamp (finds the earliest retained 
snapshot at or after the given time):

Reply via email to