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 0d4a8142 feat(datafusion): support tag retention in create_tag (#1066)
0d4a8142 is described below

commit 0d4a8142a9b1cc19528852081ade600edd2dbbfb
Author: QuakeWang <[email protected]>
AuthorDate: Thu Oct 8 14:46:27 2026 +0800

    feat(datafusion): support tag retention in create_tag (#1066)
---
 crates/integrations/datafusion/src/procedures.rs   |   7 +-
 crates/integrations/datafusion/tests/procedures.rs | 167 +++++++++++++++++++++
 docs/src/sql.md                                    |  18 +++
 3 files changed, 189 insertions(+), 3 deletions(-)

diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index c9cada57..84f014c7 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -18,7 +18,7 @@
 //! CALL procedure support for Paimon tables.
 //!
 //! Supported procedures:
-//! - `CALL sys.create_tag(table => '...', tag => '...', snapshot_id => ...)`
+//! - `CALL sys.create_tag(table => '...', tag => '...', snapshot_id => ..., 
time_retained => '...')`
 //! - `CALL sys.delete_tag(table => '...', tag => '...')`
 //! - `CALL sys.rename_tag(table => '...', tag => '...', target_tag => '...')`
 //! - `CALL sys.rollback_to(table => '...', snapshot_id => ... | tag => '...')`
@@ -168,7 +168,7 @@ async fn earlier_or_equal_from_all(
 /// a misspelled `index_type` on `create_global_index` would build the default 
index.
 fn declared_parameters(proc_name: &str) -> Option<&'static [&'static str]> {
     Some(match proc_name {
-        "create_tag" => &["table", "tag", "snapshot_id"],
+        "create_tag" => &["table", "tag", "snapshot_id", "time_retained"],
         "delete_tag" => &["table", "tag"],
         "rename_tag" => &["table", "tag", "target_tag"],
         "rollback_to" => &["table", "snapshot_id", "tag"],
@@ -439,6 +439,7 @@ async fn proc_create_tag(
                 .map_err(|_| DataFusionError::Plan(format!("Invalid 
snapshot_id: '{s}'")))
         })
         .transpose()?;
+    let time_retained = args.get("time_retained").map(String::as_str);
 
     let (sm, tm) = managers(&table);
     if tm.tag_exists(tag_name).await.map_err(to_datafusion_error)? {
@@ -454,7 +455,7 @@ async fn proc_create_tag(
             .map_err(to_datafusion_error)?
             .ok_or_else(|| DataFusionError::Plan("No snapshots 
exist".to_string()))?
     };
-    tm.create(tag_name, &snapshot)
+    tm.create_with_retention(tag_name, &snapshot, time_retained)
         .await
         .map_err(to_datafusion_error)?;
     ok_result(ctx)
diff --git a/crates/integrations/datafusion/tests/procedures.rs 
b/crates/integrations/datafusion/tests/procedures.rs
index be6714bd..f2b21646 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -21,6 +21,7 @@ use common::{
     assert_sql_error, collect_id_name, create_sql_context, create_test_env, 
exec, row_count,
     setup_sql_context,
 };
+use datafusion::arrow::array::{Array, Int64Array, StringArray};
 use paimon::catalog::Identifier;
 use paimon::table::BranchManager;
 use paimon::Catalog;
@@ -91,6 +92,172 @@ async fn test_create_tag_with_snapshot_id() {
     assert_eq!(count, 1);
 }
 
+#[tokio::test]
+async fn test_create_tag_retention() {
+    let (tmp, sql_context) = setup_table_with_snapshots().await;
+    let table_dir = tmp.path().join("test_db.db/t1");
+
+    for (name, args, snapshot_id, expected_retention) in [
+        (
+            "retained_explicit",
+            ", snapshot_id => 1, time_retained => '1d'",
+            1,
+            Some("PT24H"),
+        ),
+        (
+            "retained_latest",
+            ", time_retained => '1d'",
+            3,
+            Some("PT24H"),
+        ),
+        ("plain_explicit", ", snapshot_id => 1", 1, None),
+        ("plain_latest", "", 3, None),
+    ] {
+        let batches = sql_context
+            .sql(&format!(
+                "CALL sys.create_tag(table => 'paimon.test_db.t1', tag => 
'{name}'{args})"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(batches.len(), 1);
+        assert_eq!(batches[0].num_rows(), 1);
+        let result = batches[0]
+            .column_by_name("result")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        assert_eq!(result.value(0), "OK");
+
+        let bytes = 
std::fs::read(table_dir.join("tag").join(format!("tag-{name}"))).unwrap();
+        let tag: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
+        assert_eq!(tag["id"], snapshot_id);
+        if expected_retention.is_some() {
+            assert_eq!(
+                tag["tagTimeRetained"].as_number().unwrap().to_string(),
+                "86400.000000000"
+            );
+            assert_eq!(tag["tagCreateTime"].as_array().unwrap().len(), 7);
+        } else {
+            assert!(tag.get("tagTimeRetained").is_none());
+            assert!(tag.get("tagCreateTime").is_none());
+            let snapshot = std::fs::read(
+                table_dir
+                    .join("snapshot")
+                    .join(format!("snapshot-{snapshot_id}")),
+            )
+            .unwrap();
+            assert_eq!(bytes, snapshot);
+        }
+
+        let batches = sql_context
+            .sql(&format!(
+                "SELECT snapshot_id, create_time, time_retained \
+                 FROM paimon.test_db.`t1$tags` WHERE tag_name = '{name}'"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(batches.len(), 1);
+        assert_eq!(batches[0].num_rows(), 1);
+        let ids = batches[0]
+            .column_by_name("snapshot_id")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(ids.value(0), snapshot_id);
+        let created = batches[0].column_by_name("create_time").unwrap();
+        assert_eq!(created.is_null(0), expected_retention.is_none());
+        let retained = batches[0]
+            .column_by_name("time_retained")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        let actual = (!retained.is_null(0)).then(|| retained.value(0));
+        assert_eq!(actual, expected_retention);
+    }
+}
+
+#[tokio::test]
+async fn test_create_tag_with_invalid_retention_does_not_write() {
+    let (tmp, sql_context) = setup_table_with_snapshots().await;
+    let tag_path = tmp.path().join("test_db.db/t1/tag/tag-invalid");
+
+    for retention in [
+        "",
+        "invalid",
+        "-1d",
+        "106751991167301d",
+        "9223372036854775807h",
+        "9223372036854775808ns",
+    ] {
+        assert_sql_error(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag(table => 'test_db.t1', tag => 'invalid', \
+                 snapshot_id => 1, time_retained => '{retention}')"
+            ),
+            "Invalid tag retention",
+        )
+        .await;
+        assert!(!tag_path.exists(), "{retention:?}");
+    }
+    assert_eq!(
+        row_count(&sql_context, "SELECT * FROM 
paimon.test_db.`t1$tags`").await,
+        0
+    );
+}
+
+#[tokio::test]
+async fn test_create_tag_with_retention_preserves_existing_tag() {
+    let (tmp, sql_context) = setup_table_with_snapshots().await;
+    exec(
+        &sql_context,
+        "CALL sys.create_tag(table => 'test_db.t1', tag => 'retained', \
+         snapshot_id => 1, time_retained => '1d')",
+    )
+    .await;
+    let tag_path = tmp.path().join("test_db.db/t1/tag/tag-retained");
+    let original = std::fs::read(&tag_path).unwrap();
+
+    for retention in ["2d", "invalid"] {
+        assert_sql_error(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag(table => 'test_db.t1', tag => 'retained', 
\
+                 snapshot_id => 3, time_retained => '{retention}')"
+            ),
+            "Tag 'retained' already exists",
+        )
+        .await;
+        assert_eq!(std::fs::read(&tag_path).unwrap(), original);
+    }
+}
+
+#[tokio::test]
+async fn test_create_tag_rejects_unknown_retention_argument() {
+    let (tmp, sql_context) = setup_table_with_snapshots().await;
+    assert_sql_error(
+        &sql_context,
+        "CALL sys.create_tag(table => 'test_db.t1', tag => 'invalid', \
+         snapshot_id => 1, time_retain => '1d')",
+        "Argument time_retain is unknown",
+    )
+    .await;
+    assert!(!tmp.path().join("test_db.db/t1/tag/tag-invalid").exists());
+    assert_eq!(
+        row_count(&sql_context, "SELECT * FROM 
paimon.test_db.`t1$tags`").await,
+        0
+    );
+}
+
 #[tokio::test]
 async fn test_rename_branch() {
     let (_tmp, catalog) = create_test_env();
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 428b16e4..a37f1774 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1060,6 +1060,24 @@ Create a named tag from a snapshot:
 CALL sys.create_tag(table => 'paimon.my_db.my_table', tag => 'my_tag', 
snapshot_id => 1);
 ```
 
+`snapshot_id` is optional; omitting it uses the latest snapshot. The optional
+`time_retained` parameter accepts duration strings such as `'1d'`, `'12h'`, 
`'30min'`,
+or `'500ms'`. A duration string without a unit denotes milliseconds.
+
+```sql
+CALL sys.create_tag(
+    table => 'paimon.my_db.my_table',
+    tag => 'daily_backup',
+    snapshot_id => 1,
+    time_retained => '1d'
+);
+```
+
+When `time_retained` is specified, retention and tag creation time are 
persisted in
+the tag file. For example, `'1d'` appears as `PT24H` in the `time_retained` 
column
+of `$tags`. Omitting `time_retained` creates a tag without these metadata 
fields.
+This procedure does not automatically delete expired tags.
+
 ### create_tag_from_timestamp
 
 Create a named tag from a timestamp (finds the latest snapshot at or before 
the given time):

Reply via email to