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 d0ae928b feat(catalog): add tag lifecycle APIs (#824)
d0ae928b is described below

commit d0ae928b729bcc6fe677a491eee440c4786e1e92
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Sep 14 18:26:25 2026 +0800

    feat(catalog): add tag lifecycle APIs (#824)
---
 crates/paimon/src/api/api_request.rs           |  32 +++
 crates/paimon/src/api/api_response.rs          |  12 +-
 crates/paimon/src/api/mod.rs                   |   8 +-
 crates/paimon/src/api/resource_paths.rs        |  28 +++
 crates/paimon/src/api/rest_api.rs              |  59 +++++-
 crates/paimon/src/catalog/filesystem.rs        | 278 ++++++++++++++++++++++++-
 crates/paimon/src/catalog/mod.rs               |  37 +++-
 crates/paimon/src/catalog/rest/rest_catalog.rs | 136 +++++++++++-
 crates/paimon/src/error.rs                     |   4 +
 crates/paimon/src/table/tag_manager.rs         |  93 ++++++++-
 crates/paimon/tests/mock_server.rs             | 116 ++++++++++-
 crates/paimon/tests/rest_catalog_test.rs       |  51 +++++
 12 files changed, 828 insertions(+), 26 deletions(-)

diff --git a/crates/paimon/src/api/api_request.rs 
b/crates/paimon/src/api/api_request.rs
index 8c721fdf..42ac80ad 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -72,6 +72,25 @@ pub struct RenameTableRequest {
     pub destination: Identifier,
 }
 
+/// Request to create a table tag.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateTagRequest {
+    pub tag_name: String,
+    pub snapshot_id: Option<i64>,
+    pub time_retained: Option<String>,
+}
+
+impl CreateTagRequest {
+    pub fn new(tag_name: String, snapshot_id: Option<i64>) -> Self {
+        Self {
+            tag_name,
+            snapshot_id,
+            time_retained: None,
+        }
+    }
+}
+
 impl RenameTableRequest {
     /// Create a new RenameTableRequest.
     pub fn new(source: Identifier, destination: Identifier) -> Self {
@@ -349,6 +368,19 @@ mod tests {
         assert!(json.contains("\"options\""));
     }
 
+    #[test]
+    fn test_create_tag_request_serialization() {
+        let request = CreateTagRequest::new("release-1".to_string(), Some(42));
+        assert_eq!(
+            serde_json::to_value(request).unwrap(),
+            serde_json::json!({
+                "tagName": "release-1",
+                "snapshotId": 42,
+                "timeRetained": null
+            })
+        );
+    }
+
     #[test]
     fn test_alter_database_request_serialization() {
         let mut updates = HashMap::new();
diff --git a/crates/paimon/src/api/api_response.rs 
b/crates/paimon/src/api/api_response.rs
index ce62a392..20d1b7b7 100644
--- a/crates/paimon/src/api/api_response.rs
+++ b/crates/paimon/src/api/api_response.rs
@@ -24,7 +24,7 @@ use std::collections::HashMap;
 
 use crate::api::management::PermissionAssignment;
 use crate::catalog::{Function, FunctionDefinition, ViewSchema};
-use crate::spec::{DataField, Schema};
+use crate::spec::{DataField, Schema, Snapshot};
 
 /// Error response from REST API calls.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -132,6 +132,16 @@ pub struct GetTableResponse {
     pub schema: Option<Schema>,
 }
 
+/// Response for getting a table tag.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetTagResponse {
+    pub tag_name: String,
+    pub snapshot: Snapshot,
+    pub tag_create_time: Option<i64>,
+    pub tag_time_retained: Option<String>,
+}
+
 /// Response for getting a persistent view.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
diff --git a/crates/paimon/src/api/mod.rs b/crates/paimon/src/api/mod.rs
index 7f765cf7..88856741 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -33,15 +33,15 @@ mod api_response;
 // Re-export request types
 pub use api_request::{
     AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateDatabaseRequest,
-    CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateViewRequest,
-    DropPartitionsRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
-    RenameTableRequest, RevokePermissionRequest,
+    CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateTagRequest,
+    CreateViewRequest, DropPartitionsRequest, ListPartitionsByFilterRequest,
+    ListPartitionsByNamesRequest, RenameTableRequest, RevokePermissionRequest,
 };
 
 // Re-export response types
 pub use api_response::{
     AuditRESTResponse, AuthTableQueryResponse, ConfigResponse, ErrorResponse, 
GetDatabaseResponse,
-    GetFunctionResponse, GetTableResponse, GetTableTokenResponse, 
GetViewResponse,
+    GetFunctionResponse, GetTableResponse, GetTableTokenResponse, 
GetTagResponse, GetViewResponse,
     ListDatabasesResponse, ListFunctionsResponse, ListPartitionsResponse, 
ListPermissionsResponse,
     ListTablesResponse, ListViewsResponse, PagedList,
 };
diff --git a/crates/paimon/src/api/resource_paths.rs 
b/crates/paimon/src/api/resource_paths.rs
index b1652172..bc911db2 100644
--- a/crates/paimon/src/api/resource_paths.rs
+++ b/crates/paimon/src/api/resource_paths.rs
@@ -33,6 +33,7 @@ impl ResourcePaths {
     const TABLES: &'static str = "tables";
     const TABLE_DETAILS: &'static str = "table-details";
     const PARTITIONS: &'static str = "partitions";
+    const TAGS: &'static str = "tags";
     const VIEWS: &'static str = "views";
     const FUNCTIONS: &'static str = "functions";
     const PERMISSIONS: &'static str = "permissions";
@@ -211,6 +212,20 @@ impl ResourcePaths {
         )
     }
 
+    /// Get the tags endpoint path for a table.
+    pub fn tags(&self, database_name: &str, table_name: &str) -> String {
+        format!("{}/{}", self.table(database_name, table_name), Self::TAGS)
+    }
+
+    /// Get the endpoint path for a table tag.
+    pub fn tag(&self, database_name: &str, table_name: &str, tag_name: &str) 
-> String {
+        format!(
+            "{}/{}",
+            self.tags(database_name, table_name),
+            RESTUtil::encode_string(tag_name)
+        )
+    }
+
     /// Get the partitions endpoint path for a table.
     pub fn partitions(&self, database_name: &str, table_name: &str) -> String {
         format!(
@@ -311,6 +326,19 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_tag_paths_encode_names() {
+        let paths = ResourcePaths::new("catalog");
+        assert_eq!(
+            paths.tags("analytics db", "events/table"),
+            "/v1/catalog/databases/analytics+db/tables/events%2Ftable/tags"
+        );
+        assert_eq!(
+            paths.tag("analytics db", "events/table", "release/1"),
+            
"/v1/catalog/databases/analytics+db/tables/events%2Ftable/tags/release%2F1"
+        );
+    }
+
     #[test]
     fn test_config_path() {
         assert_eq!(ResourcePaths::config(), "/v1/config");
diff --git a/crates/paimon/src/api/rest_api.rs 
b/crates/paimon/src/api/rest_api.rs
index da1f72b7..cac1c33f 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -30,15 +30,15 @@ use crate::Result;
 
 use super::api_request::{
     AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateDatabaseRequest,
-    CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateViewRequest,
-    DropPartitionsRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
-    RenameTableRequest, RevokePermissionRequest,
+    CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateTagRequest,
+    CreateViewRequest, DropPartitionsRequest, ListPartitionsByFilterRequest,
+    ListPartitionsByNamesRequest, RenameTableRequest, RevokePermissionRequest,
 };
 use super::api_response::{
     AuthTableQueryResponse, ConfigResponse, GetDatabaseResponse, 
GetFunctionResponse,
-    GetTableResponse, GetViewResponse, ListDatabasesResponse, 
ListFunctionsResponse,
-    ListPartitionsResponse, ListPermissionsResponse, ListTablesResponse, 
ListViewsResponse,
-    PagedList,
+    GetTableResponse, GetTagResponse, GetViewResponse, ListDatabasesResponse,
+    ListFunctionsResponse, ListPartitionsResponse, ListPermissionsResponse, 
ListTablesResponse,
+    ListViewsResponse, PagedList,
 };
 use super::auth::{AuthProviderFactory, RESTAuthFunction};
 use super::management::{ListPermissionsRequest, PermissionAssignment, 
PermissionResource};
@@ -890,6 +890,53 @@ impl RESTApi {
         Ok(())
     }
 
+    // ==================== Tag Operations ====================
+
+    pub async fn create_tag(
+        &self,
+        identifier: &Identifier,
+        tag_name: &str,
+        snapshot_id: Option<i64>,
+    ) -> Result<()> {
+        let database = identifier.database();
+        let table = identifier.object();
+        validate_non_empty_multi(&[
+            (database, "database name"),
+            (table, "table name"),
+            (tag_name, "tag name"),
+        ])?;
+        let path = self.resource_paths.tags(database, table);
+        let request = CreateTagRequest::new(tag_name.to_string(), snapshot_id);
+        let _response: serde_json::Value = self.client.post(&path, 
&request).await?;
+        Ok(())
+    }
+
+    pub async fn get_tag(&self, identifier: &Identifier, tag_name: &str) -> 
Result<GetTagResponse> {
+        let database = identifier.database();
+        let table = identifier.object();
+        validate_non_empty_multi(&[
+            (database, "database name"),
+            (table, "table name"),
+            (tag_name, "tag name"),
+        ])?;
+        let path = self.resource_paths.tag(database, table, tag_name);
+        self.client.get(&path, None::<&[(&str, &str)]>).await
+    }
+
+    pub async fn delete_tag(&self, identifier: &Identifier, tag_name: &str) -> 
Result<()> {
+        let database = identifier.database();
+        let table = identifier.object();
+        validate_non_empty_multi(&[
+            (database, "database name"),
+            (table, "table name"),
+            (tag_name, "tag name"),
+        ])?;
+        let path = self.resource_paths.tag(database, table, tag_name);
+        let _response: serde_json::Value =
+            self.client.delete(&path, None::<&[(&str, &str)]>).await?;
+        Ok(())
+    }
+
     // ==================== Commit Operations ====================
 
     /// Commit a snapshot for a table.
diff --git a/crates/paimon/src/catalog/filesystem.rs 
b/crates/paimon/src/catalog/filesystem.rs
index 8caa96eb..743f0028 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -21,18 +21,20 @@
 
 use std::collections::HashMap;
 
+use crate::api::GetTagResponse;
 use crate::catalog::{Catalog, Database, Identifier, DB_LOCATION_PROP, 
DB_SUFFIX};
 use crate::common::{CatalogOptions, Options};
 use crate::error::{ConfigInvalidSnafu, Error, Result};
 use crate::io::cache::{create_local_cache, LocalCache};
 use crate::io::FileIO;
 use crate::spec::{
-    CoreOptions, Schema, TableSchema, TableType, 
INDEX_FILE_IN_DATA_FILE_DIR_OPTION,
+    CoreOptions, Schema, Snapshot, TableSchema, TableType, 
INDEX_FILE_IN_DATA_FILE_DIR_OPTION,
     TABLE_TYPE_OPTION,
 };
 use crate::table::{ObjectTable, SchemaManager, Table};
 use async_trait::async_trait;
 use bytes::Bytes;
+use chrono::TimeZone;
 use opendal::raw::get_basename;
 use snafu::OptionExt;
 
@@ -550,6 +552,138 @@ impl Catalog for FileSystemCatalog {
             .map_err(|e| fill_table_name(e, identifier))?;
         self.save_table_schema(&table_path, &new_schema).await
     }
+
+    async fn create_tag(
+        &self,
+        identifier: &Identifier,
+        tag_name: &str,
+        snapshot_id: Option<i64>,
+        ignore_if_exists: bool,
+    ) -> Result<()> {
+        let table = self.get_table(identifier).await?;
+        let manager = table.tag_manager();
+        if manager.tag_exists(tag_name).await? {
+            return if ignore_if_exists {
+                Ok(())
+            } else {
+                Err(Error::TagAlreadyExist {
+                    tag_name: tag_name.to_string(),
+                })
+            };
+        }
+
+        let snapshot = resolve_tag_snapshot(&table, snapshot_id).await?;
+        manager.create(tag_name, &snapshot).await
+    }
+
+    async fn get_tag(&self, identifier: &Identifier, tag_name: &str) -> 
Result<GetTagResponse> {
+        let (snapshot, tag_create_time, tag_time_retained) = self
+            .get_table(identifier)
+            .await?
+            .tag_manager()
+            .get_with_raw_metadata(tag_name)
+            .await?
+            .ok_or_else(|| Error::TagNotExist {
+                tag_name: tag_name.to_string(),
+            })?;
+        Ok(GetTagResponse {
+            tag_name: tag_name.to_string(),
+            snapshot,
+            tag_create_time: tag_create_time
+                .and_then(|value| local_datetime_to_millis(&chrono::Local, 
value)),
+            tag_time_retained: 
tag_time_retained.and_then(format_tag_time_retained),
+        })
+    }
+
+    async fn delete_tag(
+        &self,
+        identifier: &Identifier,
+        tag_name: &str,
+        ignore_if_not_exists: bool,
+    ) -> Result<()> {
+        let table = self.get_table(identifier).await?;
+        let manager = table.tag_manager();
+        let Some(snapshot) = manager.get(tag_name).await? else {
+            return if ignore_if_not_exists {
+                Ok(())
+            } else {
+                Err(Error::TagNotExist {
+                    tag_name: tag_name.to_string(),
+                })
+            };
+        };
+        match table.snapshot_manager().get_snapshot(snapshot.id()).await {
+            Ok(_) => manager.delete(tag_name).await,
+            Err(Error::SnapshotNotExist { .. }) => Err(Error::Unsupported {
+                message: "deleting a tag after its snapshot expired is not 
supported by FileSystemCatalog"
+                    .to_string(),
+            }),
+            Err(error) => Err(error),
+        }
+    }
+}
+
+async fn resolve_tag_snapshot(table: &Table, snapshot_id: Option<i64>) -> 
Result<Snapshot> {
+    let snapshot_manager = table.snapshot_manager();
+    let Some(snapshot_id) = snapshot_id else {
+        return snapshot_manager
+            .get_latest_snapshot()
+            .await?
+            .ok_or_else(|| Error::DataInvalid {
+                message: "Cannot create tag because latest snapshot does not 
exist".to_string(),
+                source: None,
+            });
+    };
+
+    match snapshot_manager.get_snapshot(snapshot_id).await {
+        Ok(snapshot) => Ok(snapshot),
+        Err(Error::SnapshotNotExist { .. }) => table
+            .tag_manager()
+            .list_all()
+            .await?
+            .into_iter()
+            .map(|(_, snapshot)| snapshot)
+            .find(|snapshot| snapshot.id() == snapshot_id)
+            .ok_or(Error::SnapshotNotExist { snapshot_id }),
+        Err(error) => Err(error),
+    }
+}
+
+fn format_tag_time_retained(seconds: f64) -> Option<String> {
+    let duration = std::time::Duration::try_from_secs_f64(seconds).ok()?;
+    let total_seconds = duration.as_secs();
+    let hours = total_seconds / 3600;
+    let minutes = total_seconds % 3600 / 60;
+    let seconds = total_seconds % 60;
+    let nanos = duration.subsec_nanos();
+
+    let mut value = String::from("PT");
+    if hours != 0 {
+        value.push_str(&format!("{hours}H"));
+    }
+    if minutes != 0 {
+        value.push_str(&format!("{minutes}M"));
+    }
+    if seconds != 0 || nanos != 0 || value == "PT" {
+        value.push_str(&seconds.to_string());
+        if nanos != 0 {
+            let fraction = format!("{nanos:09}");
+            value.push('.');
+            value.push_str(fraction.trim_end_matches('0'));
+        }
+        value.push('S');
+    }
+    Some(value)
+}
+
+fn local_datetime_to_millis<Tz: TimeZone>(
+    timezone: &Tz,
+    value: chrono::NaiveDateTime,
+) -> Option<i64> {
+    timezone
+        .from_local_datetime(&value)
+        .earliest()
+        .map(|value| value.timestamp_millis())
 }
 
 /// Options whose value is baked into the on-disk layout, so changing one on a
@@ -907,6 +1041,148 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_tag_operations() {
+        let (_temp_dir, catalog) = create_test_catalog();
+        catalog
+            .create_database("db1", false, HashMap::new())
+            .await
+            .unwrap();
+        let identifier = Identifier::new("db1", "t");
+        catalog
+            .create_table(&identifier, testing_schema(), false)
+            .await
+            .unwrap();
+        give_the_table_a_snapshot(&catalog, &identifier).await;
+
+        catalog
+            .create_tag(&identifier, "release-1", Some(1), false)
+            .await
+            .unwrap();
+        assert_eq!(
+            catalog
+                .get_tag(&identifier, "release-1")
+                .await
+                .unwrap()
+                .snapshot
+                .id(),
+            1
+        );
+        assert!(matches!(
+            catalog
+                .create_tag(&identifier, "release-1", Some(1), false)
+                .await,
+            Err(Error::TagAlreadyExist { .. })
+        ));
+        catalog
+            .create_tag(&identifier, "release-1", Some(1), true)
+            .await
+            .unwrap();
+
+        catalog
+            .create_tag(&identifier, "latest", None, false)
+            .await
+            .unwrap();
+        assert_eq!(
+            catalog
+                .get_tag(&identifier, "latest")
+                .await
+                .unwrap()
+                .snapshot
+                .id(),
+            1
+        );
+        assert!(matches!(
+            catalog
+                .create_tag(&identifier, "missing", Some(2), false)
+                .await,
+            Err(Error::SnapshotNotExist { snapshot_id: 2 })
+        ));
+
+        catalog
+            .delete_tag(&identifier, "release-1", false)
+            .await
+            .unwrap();
+        assert!(matches!(
+            catalog.get_tag(&identifier, "release-1").await,
+            Err(Error::TagNotExist { .. })
+        ));
+        assert!(matches!(
+            catalog.delete_tag(&identifier, "release-1", false).await,
+            Err(Error::TagNotExist { .. })
+        ));
+        catalog
+            .delete_tag(&identifier, "release-1", true)
+            .await
+            .unwrap();
+    }
+
+    #[tokio::test]
+    async fn test_delete_expired_tag_is_unsupported() {
+        let (_temp_dir, catalog) = create_test_catalog();
+        catalog
+            .create_database("db1", false, HashMap::new())
+            .await
+            .unwrap();
+        let identifier = Identifier::new("db1", "t");
+        catalog
+            .create_table(&identifier, testing_schema(), false)
+            .await
+            .unwrap();
+        give_the_table_a_snapshot(&catalog, &identifier).await;
+        catalog
+            .create_tag(&identifier, "release-1", Some(1), false)
+            .await
+            .unwrap();
+
+        catalog
+            .get_table(&identifier)
+            .await
+            .unwrap()
+            .snapshot_manager()
+            .delete_snapshot(1)
+            .await
+            .unwrap();
+        assert!(matches!(
+            catalog.delete_tag(&identifier, "release-1", false).await,
+            Err(Error::Unsupported { .. })
+        ));
+        assert_eq!(
+            catalog
+                .get_tag(&identifier, "release-1")
+                .await
+                .unwrap()
+                .snapshot
+                .id(),
+            1
+        );
+    }
+
+    #[test]
+    fn test_format_tag_time_retained() {
+        assert_eq!(format_tag_time_retained(0.0).as_deref(), Some("PT0S"));
+        assert_eq!(format_tag_time_retained(90.0).as_deref(), Some("PT1M30S"));
+        assert_eq!(
+            format_tag_time_retained(259_200.0).as_deref(),
+            Some("PT72H")
+        );
+        assert_eq!(format_tag_time_retained(1.5).as_deref(), Some("PT1.5S"));
+    }
+
+    #[test]
+    fn test_tag_create_time_uses_catalog_timezone() {
+        let value = chrono::NaiveDate::from_ymd_opt(2024, 1, 2)
+            .unwrap()
+            .and_hms_nano_opt(3, 4, 5, 123_000_000)
+            .unwrap();
+        let shanghai = chrono::FixedOffset::east_opt(8 * 60 * 60).unwrap();
+
+        assert_eq!(
+            local_datetime_to_millis(&shanghai, value),
+            Some(1_704_135_845_123)
+        );
+    }
+
     async fn create_table_for_alter(
         catalog: &FileSystemCatalog,
         options: HashMap<String, String>,
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 38bd9f13..0c66f8be 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -261,7 +261,7 @@ impl fmt::Debug for Identifier {
 
 use async_trait::async_trait;
 
-use crate::api::PagedList;
+use crate::api::{GetTagResponse, PagedList};
 use crate::spec::{Partition, Schema, SchemaChange, TableType};
 use crate::table::{ObjectTable, Table};
 
@@ -490,6 +490,41 @@ pub trait Catalog: Send + Sync {
         ignore_if_not_exists: bool,
     ) -> Result<()>;
 
+    /// Create a tag for a snapshot, or for the latest snapshot when 
`snapshot_id` is `None`.
+    async fn create_tag(
+        &self,
+        _identifier: &Identifier,
+        _tag_name: &str,
+        _snapshot_id: Option<i64>,
+        _ignore_if_exists: bool,
+    ) -> Result<()> {
+        Err(Error::Unsupported {
+            message: "tag management is not supported by this 
catalog".to_string(),
+        })
+    }
+
+    /// Return a tag and its snapshot metadata.
+    async fn get_tag(&self, _identifier: &Identifier, _tag_name: &str) -> 
Result<GetTagResponse> {
+        Err(Error::Unsupported {
+            message: "tag management is not supported by this 
catalog".to_string(),
+        })
+    }
+
+    /// Delete a tag.
+    ///
+    /// `FileSystemCatalog` returns [`crate::Error::Unsupported`] when the 
tagged snapshot has
+    /// expired because Rust does not yet implement Java's tag file cleanup.
+    async fn delete_tag(
+        &self,
+        _identifier: &Identifier,
+        _tag_name: &str,
+        _ignore_if_not_exists: bool,
+    ) -> Result<()> {
+        Err(Error::Unsupported {
+            message: "tag management is not supported by this 
catalog".to_string(),
+        })
+    }
+
     // ======================= view methods ===============================
 
     /// Create a persistent view.
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 41ec6759..fb4f47de 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -28,7 +28,7 @@ use async_trait::async_trait;
 use crate::api::management::{ListPermissionsRequest, PermissionAssignment, 
PermissionResource};
 use crate::api::rest_api::RESTApi;
 use crate::api::rest_error::RestError;
-use crate::api::PagedList;
+use crate::api::{GetTagResponse, PagedList};
 use crate::catalog::{
     list_partitions_from_file_system, Catalog, Database, Identifier, 
DB_LOCATION_PROP,
 };
@@ -356,6 +356,57 @@ impl Catalog for RESTCatalog {
         })
     }
 
+    async fn create_tag(
+        &self,
+        identifier: &Identifier,
+        tag_name: &str,
+        snapshot_id: Option<i64>,
+        ignore_if_exists: bool,
+    ) -> Result<()> {
+        let result = self
+            .api
+            .create_tag(identifier, tag_name, snapshot_id)
+            .await
+            .map_err(|error| map_rest_error_for_tag(error, identifier, 
tag_name, snapshot_id));
+        ignore_error_if(result, |error| {
+            ignore_if_exists && matches!(error, Error::TagAlreadyExist { .. })
+        })
+    }
+
+    async fn get_tag(&self, identifier: &Identifier, tag_name: &str) -> 
Result<GetTagResponse> {
+        let response = self
+            .api
+            .get_tag(identifier, tag_name)
+            .await
+            .map_err(|error| map_rest_error_for_tag(error, identifier, 
tag_name, None))?;
+        if response.tag_name != tag_name {
+            return Err(Error::DataInvalid {
+                message: format!(
+                    "REST catalog returned tag '{}' for requested tag 
'{tag_name}'",
+                    response.tag_name
+                ),
+                source: None,
+            });
+        }
+        Ok(response)
+    }
+
+    async fn delete_tag(
+        &self,
+        identifier: &Identifier,
+        tag_name: &str,
+        ignore_if_not_exists: bool,
+    ) -> Result<()> {
+        let result = self
+            .api
+            .delete_tag(identifier, tag_name)
+            .await
+            .map_err(|error| map_rest_error_for_tag(error, identifier, 
tag_name, None));
+        ignore_error_if(result, |error| {
+            ignore_if_not_exists && matches!(error, Error::TagNotExist { .. })
+        })
+    }
+
     async fn create_view(
         &self,
         identifier: &Identifier,
@@ -638,6 +689,64 @@ fn map_rest_error_for_table(err: Error, identifier: 
&Identifier) -> Error {
     }
 }
 
+fn map_rest_error_for_tag(
+    error: Error,
+    identifier: &Identifier,
+    tag_name: &str,
+    snapshot_id: Option<i64>,
+) -> Error {
+    match error {
+        Error::RestApi {
+            source: RestError::AlreadyExists { .. },
+        } => Error::TagAlreadyExist {
+            tag_name: tag_name.to_string(),
+        },
+        Error::RestApi {
+            source:
+                RestError::NoSuchResource {
+                    resource_type,
+                    resource_name,
+                    ..
+                },
+        } if resource_type
+            .as_deref()
+            .is_some_and(|kind| kind.eq_ignore_ascii_case("snapshot")) =>
+        {
+            match snapshot_id.or_else(|| resource_name.and_then(|value| 
value.parse().ok())) {
+                Some(snapshot_id) => Error::SnapshotNotExist { snapshot_id },
+                None => Error::DataInvalid {
+                    message: format!(
+                        "Cannot create tag '{tag_name}' because the latest 
snapshot does not exist"
+                    ),
+                    source: None,
+                },
+            }
+        }
+        Error::RestApi {
+            source: RestError::NoSuchResource { resource_type, .. },
+        } if resource_type
+            .as_deref()
+            .is_some_and(|kind| kind.eq_ignore_ascii_case("tag")) =>
+        {
+            Error::TagNotExist {
+                tag_name: tag_name.to_string(),
+            }
+        }
+        Error::RestApi {
+            source: RestError::NoSuchResource { .. },
+        } => Error::TableNotExist {
+            full_name: identifier.full_name(),
+        },
+        Error::RestApi {
+            source: RestError::BadRequest { message },
+        } => Error::DataInvalid {
+            message,
+            source: None,
+        },
+        other => map_unsupported_endpoint(other, "tag"),
+    }
+}
+
 /// A partition spec in a form that can key a map.
 fn spec_key(spec: &HashMap<String, String>) -> Vec<(String, String)> {
     let mut entries = spec
@@ -837,4 +946,29 @@ mod tests {
 
         assert!(catalog.has_local_cache());
     }
+
+    #[test]
+    fn test_tag_error_mapping() {
+        let identifier = Identifier::new("db", "table");
+        let error = |resource_type: &str| Error::RestApi {
+            source: RestError::NoSuchResource {
+                resource_type: Some(resource_type.to_string()),
+                resource_name: None,
+                message: "missing".to_string(),
+            },
+        };
+
+        assert!(matches!(
+            map_rest_error_for_tag(error("tag"), &identifier, "release", None),
+            Error::TagNotExist { .. }
+        ));
+        assert!(matches!(
+            map_rest_error_for_tag(error("snapshot"), &identifier, "release", 
Some(7)),
+            Error::SnapshotNotExist { snapshot_id: 7 }
+        ));
+        assert!(matches!(
+            map_rest_error_for_tag(error("table"), &identifier, "release", 
None),
+            Error::TableNotExist { .. }
+        ));
+    }
 }
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index a8be68f1..18696cdd 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -104,6 +104,10 @@ pub enum Error {
     TableNotExist { full_name: String },
     #[snafu(display("Snapshot {} does not exist.", snapshot_id))]
     SnapshotNotExist { snapshot_id: i64 },
+    #[snafu(display("Tag {} already exists.", tag_name))]
+    TagAlreadyExist { tag_name: String },
+    #[snafu(display("Tag {} does not exist.", tag_name))]
+    TagNotExist { tag_name: String },
     #[snafu(display("View {} already exists.", full_name))]
     ViewAlreadyExist { full_name: String },
     #[snafu(display("View {} does not exist.", full_name))]
diff --git a/crates/paimon/src/table/tag_manager.rs 
b/crates/paimon/src/table/tag_manager.rs
index 8bf1a384..668dc9b3 100644
--- a/crates/paimon/src/table/tag_manager.rs
+++ b/crates/paimon/src/table/tag_manager.rs
@@ -66,6 +66,7 @@ impl TagManager {
 
     /// Check if a tag exists.
     pub async fn tag_exists(&self, tag_name: &str) -> crate::Result<bool> {
+        validate_tag_name(tag_name)?;
         let path = self.tag_path(tag_name);
         let input = self.file_io.new_input(&path)?;
         input.exists().await
@@ -76,6 +77,7 @@ impl TagManager {
     /// Tag files are JSON with the same schema as Snapshot.
     /// Reads directly and catches NotFound to avoid a separate exists() IO 
round-trip.
     pub async fn get(&self, tag_name: &str) -> crate::Result<Option<Snapshot>> 
{
+        validate_tag_name(tag_name)?;
         let path = self.tag_path(tag_name);
         let input = self.file_io.new_input(&path)?;
         let bytes = match input.read().await {
@@ -103,6 +105,22 @@ impl TagManager {
         &self,
         tag_name: &str,
     ) -> crate::Result<Option<(Snapshot, Option<i64>, Option<f64>)>> {
+        Ok(self.get_with_raw_metadata(tag_name).await?.map(
+            |(snapshot, create_time, time_retained)| {
+                (
+                    snapshot,
+                    create_time.map(|value| 
value.and_utc().timestamp_millis()),
+                    time_retained,
+                )
+            },
+        ))
+    }
+
+    pub(crate) async fn get_with_raw_metadata(
+        &self,
+        tag_name: &str,
+    ) -> crate::Result<Option<(Snapshot, Option<chrono::NaiveDateTime>, 
Option<f64>)>> {
+        validate_tag_name(tag_name)?;
         let path = self.tag_path(tag_name);
         let input = self.file_io.new_input(&path)?;
         let bytes = match input.read().await {
@@ -126,7 +144,7 @@ impl TagManager {
             })?;
         let create_time = value
             .get(FIELD_TAG_CREATE_TIME)
-            .and_then(parse_tag_create_time_millis);
+            .and_then(parse_tag_create_time);
         let time_retained = value
             .get(FIELD_TAG_TIME_RETAINED)
             .and_then(serde_json::Value::as_f64);
@@ -181,17 +199,20 @@ impl TagManager {
 
     /// Create a tag by writing the snapshot JSON to the tag path.
     pub async fn create(&self, tag_name: &str, snapshot: &Snapshot) -> 
crate::Result<()> {
+        validate_tag_name(tag_name)?;
         let path = self.tag_path(tag_name);
         let json = serde_json::to_string(snapshot).map_err(|e| 
crate::Error::DataInvalid {
             message: format!("failed to serialize snapshot for tag 
'{tag_name}': {e}"),
             source: Some(Box::new(e)),
         })?;
+        self.file_io.mkdirs(&self.tag_directory()).await?;
         let output = self.file_io.new_output(&path)?;
         output.write(bytes::Bytes::from(json)).await
     }
 
     /// Delete a tag file.
     pub async fn delete(&self, tag_name: &str) -> crate::Result<()> {
+        validate_tag_name(tag_name)?;
         let path = self.tag_path(tag_name);
         self.file_io.delete_file(&path).await
     }
@@ -217,15 +238,28 @@ impl TagManager {
 const FIELD_TAG_CREATE_TIME: &str = "tagCreateTime";
 const FIELD_TAG_TIME_RETAINED: &str = "tagTimeRetained";
 
-/// Decode a Jackson-serialized `LocalDateTime` into epoch millis, treating the
-/// wall-clock value as UTC.
+fn validate_tag_name(tag_name: &str) -> crate::Result<()> {
+    let invalid = tag_name.trim().is_empty()
+        || tag_name.trim_end() != tag_name
+        || tag_name.contains('/')
+        || tag_name.contains('\\')
+        || tag_name.chars().any(char::is_control);
+    if invalid {
+        return Err(crate::Error::ConfigInvalid {
+            message: format!("Invalid tag name: {tag_name:?}"),
+        });
+    }
+    Ok(())
+}
+
+/// Decode a Jackson-serialized `LocalDateTime`.
 ///
 /// Jackson's `LocalDateTimeSerializer` emits
 /// `[year, month, day, hour, minute, second, nanoOfSecond]` and omits trailing
 /// zero components, so the array may hold as few as five items. Anything that 
is
 /// not such an array -- or that does not describe a real instant -- yields
 /// `None` so one odd tag file cannot fail the whole listing.
-fn parse_tag_create_time_millis(value: &serde_json::Value) -> Option<i64> {
+fn parse_tag_create_time(value: &serde_json::Value) -> 
Option<chrono::NaiveDateTime> {
     let items = value.as_array()?;
     if items.len() < 5 || items.len() > 7 {
         return None;
@@ -247,7 +281,7 @@ fn parse_tag_create_time_millis(value: &serde_json::Value) 
-> Option<i64> {
         u32::try_from(second).ok()?,
         u32::try_from(nano).ok()?,
     )?;
-    Some(date.and_time(time).and_utc().timestamp_millis())
+    Some(date.and_time(time))
 }
 
 #[cfg(test)]
@@ -290,6 +324,55 @@ mod tests {
         assert!(tm.list_all().await.unwrap().is_empty());
     }
 
+    #[tokio::test]
+    async fn test_tag_operations_reject_unsafe_names() {
+        let tm = TagManager::new(test_file_io(), 
"memory:/warehouse/table".to_string());
+        let snapshot = test_snapshot(1);
+
+        for name in ["", " ", "tag ", "nested/tag", "nested\\tag", 
"bad\nname"] {
+            assert!(matches!(
+                tm.create(name, &snapshot).await,
+                Err(crate::Error::ConfigInvalid { .. })
+            ));
+            assert!(matches!(
+                tm.tag_exists(name).await,
+                Err(crate::Error::ConfigInvalid { .. })
+            ));
+            assert!(matches!(
+                tm.get(name).await,
+                Err(crate::Error::ConfigInvalid { .. })
+            ));
+            assert!(matches!(
+                tm.delete(name).await,
+                Err(crate::Error::ConfigInvalid { .. })
+            ));
+        }
+    }
+
+    #[tokio::test]
+    async fn test_leading_whitespace_tag_name_compatibility() {
+        let tm = TagManager::new(test_file_io(), 
"memory:/warehouse/table".to_string());
+        let snapshot = test_snapshot(1);
+
+        tm.create(" tag", &snapshot).await.unwrap();
+        assert_eq!(tm.get(" tag").await.unwrap(), Some(snapshot));
+        assert_eq!(tm.list_all().await.unwrap().len(), 1);
+        assert_eq!(tm.list_all_with_metadata().await.unwrap().len(), 1);
+    }
+
+    #[tokio::test]
+    async fn test_trailing_whitespace_does_not_alias_tag() {
+        let tm = TagManager::new(test_file_io(), 
"memory:/warehouse/table".to_string());
+        let snapshot = test_snapshot(1);
+
+        tm.create("tag", &snapshot).await.unwrap();
+        assert!(matches!(
+            tm.delete("tag ").await,
+            Err(crate::Error::ConfigInvalid { .. })
+        ));
+        assert_eq!(tm.get("tag").await.unwrap(), Some(snapshot));
+    }
+
     #[tokio::test]
     async fn test_list_all_names_sorted() {
         let file_io = test_file_io();
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index 81572025..929e1650 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -35,15 +35,16 @@ use tokio::task::JoinHandle;
 
 use paimon::api::{
     AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse, ConfigResponse,
-    CreateFunctionRequest, CreatePartitionsRequest, CreateViewRequest, 
DropPartitionsRequest,
-    ErrorResponse, GetDatabaseResponse, GetFunctionResponse, GetTableResponse, 
GetViewResponse,
-    ListDatabasesResponse, ListFunctionsResponse, 
ListPartitionsByFilterRequest,
-    ListPartitionsByNamesRequest, ListPartitionsResponse, 
ListPermissionsResponse,
-    ListTablesResponse, ListViewsResponse, PermissionAssignment, 
PermissionResource,
-    RenameTableRequest, ResourcePaths, ResourceType, RevokePermissionRequest,
+    CreateFunctionRequest, CreatePartitionsRequest, CreateTagRequest, 
CreateViewRequest,
+    DropPartitionsRequest, ErrorResponse, GetDatabaseResponse, 
GetFunctionResponse,
+    GetTableResponse, GetTagResponse, GetViewResponse, ListDatabasesResponse,
+    ListFunctionsResponse, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
+    ListPartitionsResponse, ListPermissionsResponse, ListTablesResponse, 
ListViewsResponse,
+    PermissionAssignment, PermissionResource, RenameTableRequest, 
ResourcePaths, ResourceType,
+    RevokePermissionRequest,
 };
 use paimon::catalog::{Function, Identifier};
-use paimon::spec::Partition;
+use paimon::spec::{CommitKind, Partition, Snapshot};
 
 type PartitionPageResponse = (Vec<Partition>, Option<String>);
 type PartitionSpecPageResponse = (Vec<HashMap<String, String>>, 
Option<String>);
@@ -54,6 +55,7 @@ struct MockState {
     tables: HashMap<String, GetTableResponse>,
     views: HashMap<String, GetViewResponse>,
     functions: HashMap<String, GetFunctionResponse>,
+    tags: HashMap<String, GetTagResponse>,
     partitions: HashMap<String, Vec<Partition>>,
     partition_page_responses: HashMap<String, Vec<PartitionPageResponse>>,
     partition_list_call_counts: HashMap<String, usize>,
@@ -117,6 +119,39 @@ fn partition_from_spec(spec: HashMap<String, String>) -> 
Partition {
     }
 }
 
+fn tag_snapshot(id: i64) -> Snapshot {
+    Snapshot::builder()
+        .version(3)
+        .id(id)
+        .schema_id(0)
+        .base_manifest_list("base-list".to_string())
+        .delta_manifest_list("delta-list".to_string())
+        .commit_user("test-user".to_string())
+        .commit_identifier(0)
+        .commit_kind(CommitKind::APPEND)
+        .time_millis(1000)
+        .build()
+}
+
+fn resource_error(
+    status: StatusCode,
+    resource_type: &str,
+    resource_name: &str,
+) -> axum::response::Response {
+    let message = if status == StatusCode::CONFLICT {
+        "Already Exists"
+    } else {
+        "Not Found"
+    };
+    let error = ErrorResponse::new(
+        Some(resource_type.to_string()),
+        Some(resource_name.to_string()),
+        Some(message.to_string()),
+        Some(status.as_u16() as i32),
+    );
+    (status, Json(error)).into_response()
+}
+
 fn paginate<T: Clone>(
     items: Vec<T>,
     params: &HashMap<String, String>,
@@ -837,6 +872,65 @@ impl RESTServer {
         }
     }
 
+    pub async fn create_tag(
+        Path((db, table)): Path<(String, String)>,
+        Extension(state): Extension<Arc<RESTServer>>,
+        Json(request): Json<CreateTagRequest>,
+    ) -> impl IntoResponse {
+        let mut state = state.inner.lock().unwrap();
+        if !state.tables.contains_key(&format!("{db}.{table}")) {
+            return resource_error(StatusCode::NOT_FOUND, "table", &table);
+        }
+
+        let key = format!("{db}.{table}.{}", request.tag_name);
+        if state.tags.contains_key(&key) {
+            return resource_error(StatusCode::CONFLICT, "tag", 
&request.tag_name);
+        }
+        let snapshot_id = request.snapshot_id.unwrap_or(1);
+        if snapshot_id != 1 {
+            return resource_error(StatusCode::NOT_FOUND, "snapshot", 
&snapshot_id.to_string());
+        }
+        state.tags.insert(
+            key,
+            GetTagResponse {
+                tag_name: request.tag_name,
+                snapshot: tag_snapshot(snapshot_id),
+                tag_create_time: None,
+                tag_time_retained: request.time_retained,
+            },
+        );
+        (StatusCode::OK, Json(json!(""))).into_response()
+    }
+
+    pub async fn get_tag(
+        Path((db, table, tag)): Path<(String, String, String)>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let state = state.inner.lock().unwrap();
+        if !state.tables.contains_key(&format!("{db}.{table}")) {
+            return resource_error(StatusCode::NOT_FOUND, "table", &table);
+        }
+        match state.tags.get(&format!("{db}.{table}.{tag}")) {
+            Some(response) => (StatusCode::OK, 
Json(response.clone())).into_response(),
+            None => resource_error(StatusCode::NOT_FOUND, "tag", &tag),
+        }
+    }
+
+    pub async fn delete_tag(
+        Path((db, table, tag)): Path<(String, String, String)>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let mut state = state.inner.lock().unwrap();
+        if !state.tables.contains_key(&format!("{db}.{table}")) {
+            return resource_error(StatusCode::NOT_FOUND, "table", &table);
+        }
+        if state.tags.remove(&format!("{db}.{table}.{tag}")).is_some() {
+            (StatusCode::OK, Json(json!(""))).into_response()
+        } else {
+            resource_error(StatusCode::NOT_FOUND, "tag", &tag)
+        }
+    }
+
     /// Handle POST /databases/:db/tables/:table/partitions - create 
partitions.
     pub async fn create_partitions(
         Path((db, table)): Path<(String, String)>,
@@ -1815,6 +1909,14 @@ pub async fn start_mock_server(
                 .post(RESTServer::alter_table)
                 .delete(RESTServer::drop_table),
         )
+        .route(
+            &format!("{prefix}/databases/:db/tables/:table/tags"),
+            post(RESTServer::create_tag),
+        )
+        .route(
+            &format!("{prefix}/databases/:db/tables/:table/tags/:tag"),
+            get(RESTServer::get_tag).delete(RESTServer::delete_tag),
+        )
         .route(
             &format!("{prefix}/databases/:db/tables/:table/partitions"),
             
get(RESTServer::list_partitions).post(RESTServer::create_partitions),
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index 691c1159..3d7805e0 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1761,6 +1761,57 @@ async fn test_catalog_alter_table() {
         .unwrap();
 }
 
+#[tokio::test]
+async fn test_catalog_tag_lifecycle() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    ctx.server.add_table("default", "managed_table");
+    let identifier = Identifier::new("default", "managed_table");
+
+    ctx.catalog
+        .create_tag(&identifier, "release-1", Some(1), false)
+        .await
+        .unwrap();
+    let tag = ctx.catalog.get_tag(&identifier, "release-1").await.unwrap();
+    assert_eq!(tag.tag_name, "release-1");
+    assert_eq!(tag.snapshot.id(), 1);
+
+    assert!(matches!(
+        ctx.catalog
+            .create_tag(&identifier, "release-1", Some(1), false)
+            .await,
+        Err(paimon::Error::TagAlreadyExist { .. })
+    ));
+    ctx.catalog
+        .create_tag(&identifier, "release-1", Some(1), true)
+        .await
+        .unwrap();
+    assert!(matches!(
+        ctx.catalog
+            .create_tag(&identifier, "missing", Some(2), false)
+            .await,
+        Err(paimon::Error::SnapshotNotExist { snapshot_id: 2 })
+    ));
+
+    ctx.catalog
+        .delete_tag(&identifier, "release-1", false)
+        .await
+        .unwrap();
+    assert!(matches!(
+        ctx.catalog.get_tag(&identifier, "release-1").await,
+        Err(paimon::Error::TagNotExist { .. })
+    ));
+    assert!(matches!(
+        ctx.catalog
+            .delete_tag(&identifier, "release-1", false)
+            .await,
+        Err(paimon::Error::TagNotExist { .. })
+    ));
+    ctx.catalog
+        .delete_tag(&identifier, "release-1", true)
+        .await
+        .unwrap();
+}
+
 // ==================== Multiple Databases Tests ====================
 
 #[tokio::test]

Reply via email to