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 9133b996 feat(datafusion): support create_tag_from_watermark (#1134)
9133b996 is described below

commit 9133b996d7ff1b4514a31e04f1f6b3110ab144b3
Author: QuakeWang <[email protected]>
AuthorDate: Sun Oct 18 16:00:55 2026 +0800

    feat(datafusion): support create_tag_from_watermark (#1134)
---
 crates/integrations/datafusion/src/procedures.rs   |  74 ++++++
 crates/integrations/datafusion/tests/procedures.rs | 277 +++++++++++++++++++++
 .../datafusion/tests/rest_management_procedures.rs | 145 +++++++++++
 docs/src/sql.md                                    |  35 ++-
 4 files changed, 530 insertions(+), 1 deletion(-)

diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index 3124a863..9377e2e8 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -24,6 +24,7 @@
 //! - `CALL sys.rollback_to(table => '...', snapshot_id => ... | tag => '...')`
 //! - `CALL sys.rollback_to_timestamp(table => '...', timestamp => ...)`
 //! - `CALL sys.create_tag_from_timestamp(table => '...', tag => '...', 
timestamp => ...)`
+//! - `CALL sys.create_tag_from_watermark(table => '...', tag => '...', 
watermark => ..., time_retained => '...')`
 //! - `CALL sys.create_global_index(table => '...', index_column => '...', 
index_type => 'btree')`
 //! - `CALL sys.create_global_index(table => '...', index_column => '...', 
index_type => 'bitmap')`
 //! - `CALL sys.create_global_index(table => '...', index_column => '...', 
index_type => 'ivf-pq')`
@@ -134,6 +135,35 @@ async fn later_or_equal_from_all(
     }
 }
 
+/// Find a snapshot with watermark >= the requested value, including 
tag-retained
+/// snapshots. Matches Java's Spark procedure: compare the first eligible live 
and
+/// tagged snapshots by watermark, then by snapshot ID when watermarks are 
equal.
+async fn later_or_equal_watermark_from_all(
+    sm: &SnapshotManager,
+    tm: &TagManager,
+    watermark: i64,
+) -> DFResult<Option<Snapshot>> {
+    let live = sm
+        .later_or_equal_watermark(watermark)
+        .await
+        .map_err(to_datafusion_error)?;
+    let tags = tm.list_all().await.map_err(to_datafusion_error)?;
+    let tag_candidate = tags
+        .into_iter()
+        .map(|(_, snapshot)| snapshot)
+        .filter(|snapshot| {
+            snapshot
+                .watermark()
+                .is_some_and(|value| value != i64::MIN && value >= watermark)
+        })
+        // Rust lists tags by name; Java's tag candidates are ordered by 
snapshot ID.
+        .min_by_key(Snapshot::id);
+    Ok(live
+        .into_iter()
+        .chain(tag_candidate)
+        .min_by_key(|snapshot| (snapshot.watermark(), snapshot.id())))
+}
+
 /// Find the latest snapshot with commit time <= timestamp_millis,
 /// considering both live snapshots and tag-retained snapshots.
 async fn earlier_or_equal_from_all(
@@ -177,6 +207,7 @@ fn declared_parameters(proc_name: &str) -> Option<&'static 
[&'static str]> {
         "rollback_to" => &["table", "snapshot_id", "tag"],
         "rollback_to_timestamp" => &["table", "timestamp"],
         "create_tag_from_timestamp" => &["table", "tag", "timestamp"],
+        "create_tag_from_watermark" => &["table", "tag", "watermark", 
"time_retained"],
         "rename_branch" => &["table", "from_branch", "to_branch"],
         "delete_branch" => &["table", "branch"],
         "create_global_index" => &["table", "index_column", "index_type", 
"options"],
@@ -291,6 +322,9 @@ pub async fn execute_call(
         "create_tag_from_timestamp" => {
             proc_create_tag_from_timestamp(ctx, catalog, catalog_name, 
&args).await
         }
+        "create_tag_from_watermark" => {
+            proc_create_tag_from_watermark(ctx, catalog, catalog_name, 
&args).await
+        }
         "rename_branch" => proc_rename_branch(ctx, catalog, catalog_name, 
&args).await,
         "delete_branch" => proc_delete_branch(ctx, catalog, catalog_name, 
&args).await,
         "create_global_index" => proc_create_global_index(ctx, catalog, 
catalog_name, &args).await,
@@ -778,6 +812,46 @@ async fn proc_create_tag_from_timestamp(
     ok_result(ctx)
 }
 
+async fn proc_create_tag_from_watermark(
+    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 watermark_str = require_arg(args, "watermark")?;
+    let watermark: i64 = watermark_str
+        .parse()
+        .map_err(|_| DataFusionError::Plan(format!("Invalid watermark: 
'{watermark_str}'")))?;
+    let time_retained = args.get("time_retained").map(String::as_str);
+
+    // REST snapshots may exist only in the catalog; storage listings cannot
+    // enumerate the authoritative history needed for watermark selection.
+    if table.rest_env().is_some() {
+        return Err(DataFusionError::NotImplemented(
+            "create_tag_from_watermark is not supported for tables managed by 
a REST catalog"
+                .to_string(),
+        ));
+    }
+
+    let (sm, tm) = managers(&table);
+    if tm.tag_exists(tag_name).await.map_err(to_datafusion_error)? {
+        return Err(DataFusionError::Plan(format!(
+            "Tag '{tag_name}' already exists"
+        )));
+    }
+    let snapshot = later_or_equal_watermark_from_all(&sm, &tm, watermark)
+        .await?
+        .ok_or_else(|| {
+            DataFusionError::Plan(format!("No snapshot found with watermark >= 
{watermark}"))
+        })?;
+    tm.create_with_retention(tag_name, &snapshot, time_retained)
+        .await
+        .map_err(to_datafusion_error)?;
+    ok_result(ctx)
+}
+
 async fn proc_create_lumina_index(
     ctx: &SessionContext,
     catalog: &Arc<dyn Catalog>,
diff --git a/crates/integrations/datafusion/tests/procedures.rs 
b/crates/integrations/datafusion/tests/procedures.rs
index f4463685..5279155a 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -56,6 +56,27 @@ async fn setup_table_with_snapshots() -> (tempfile::TempDir, 
paimon_datafusion::
     (tmp, sql_context)
 }
 
+async fn setup_table_with_watermarks(
+    watermarks: [Option<i64>; 3],
+) -> (tempfile::TempDir, paimon_datafusion::SQLContext) {
+    let (tmp, sql_context) = setup_table_with_snapshots().await;
+    for (index, watermark) in watermarks.into_iter().enumerate() {
+        let path = tmp
+            .path()
+            .join(format!("test_db.db/t1/snapshot/snapshot-{}", index + 1));
+        let mut snapshot: serde_json::Value =
+            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+        snapshot["watermark"] = serde_json::json!(watermark);
+        std::fs::write(path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
+    }
+    (tmp, sql_context)
+}
+
+fn read_tag(tmp: &Path, name: &str) -> serde_json::Value {
+    let path = tmp.join(format!("test_db.db/t1/tag/tag-{name}"));
+    serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
+}
+
 #[tokio::test]
 async fn test_create_tag() {
     let (_tmp, sql_context) = setup_table_with_snapshots().await;
@@ -2759,6 +2780,262 @@ async fn test_create_tag_from_timestamp() {
     assert_eq!(count, 1);
 }
 
+#[tokio::test]
+async fn test_create_tag_from_watermark_selects_first_live_snapshot() {
+    for (watermark, expected_id) in [(500, 1), (1000, 1), (1500, 2), (2000, 
2)] {
+        let (tmp, sql_context) =
+            setup_table_with_watermarks([Some(1000), Some(2000), 
Some(2000)]).await;
+        let tag_name = format!("wm_{watermark}");
+        exec(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag_from_watermark(table => 
'paimon.test_db.t1', \
+                 tag => '{tag_name}', watermark => {watermark})"
+            ),
+        )
+        .await;
+        let tag = read_tag(tmp.path(), &tag_name);
+        assert_eq!(tag["id"], expected_id);
+        assert!(tag.get("tagTimeRetained").is_none());
+        assert!(tag.get("tagCreateTime").is_none());
+        assert_eq!(
+            row_count(
+                &sql_context,
+                &format!("SELECT * FROM paimon.test_db.t1 VERSION AS OF 
'{tag_name}'"),
+            )
+            .await,
+            expected_id as usize
+        );
+    }
+}
+
+#[tokio::test]
+async fn test_create_tag_from_watermark_uses_retained_snapshots() {
+    let (tmp, sql_context) =
+        setup_table_with_watermarks([Some(1000), Some(2000), 
Some(3000)]).await;
+    let snapshot_dir = tmp.path().join("test_db.db/t1/snapshot");
+    // Tag names deliberately put the later snapshot first in Rust's tag 
listing.
+    for (name, id) in [("z_earlier", 1), ("a_later", 2)] {
+        exec(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag(table => 'test_db.t1', tag => '{name}', 
snapshot_id => {id})"
+            ),
+        )
+        .await;
+        
std::fs::remove_file(snapshot_dir.join(format!("snapshot-{id}"))).unwrap();
+    }
+
+    for (watermark, expected_id) in [(999, 1), (1000, 1), (1500, 2), (2000, 
2), (2500, 3)] {
+        let tag_name = format!("wm_{watermark}");
+        exec(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag_from_watermark(table => 'test_db.t1', \
+                 tag => '{tag_name}', watermark => {watermark}, time_retained 
=> '7d')"
+            ),
+        )
+        .await;
+        let tag = read_tag(tmp.path(), &tag_name);
+        assert_eq!(tag["id"], expected_id);
+        assert_eq!(
+            tag["tagTimeRetained"].as_number().unwrap().to_string(),
+            "604800.000000000"
+        );
+        assert_eq!(tag["tagCreateTime"].as_array().unwrap().len(), 7);
+        assert_eq!(
+            row_count(
+                &sql_context,
+                &format!("SELECT * FROM paimon.test_db.t1 VERSION AS OF 
'{tag_name}'"),
+            )
+            .await,
+            expected_id as usize
+        );
+    }
+
+    // All snapshot files can be gone while tag metadata still supplies a 
candidate.
+    std::fs::remove_file(snapshot_dir.join("snapshot-3")).unwrap();
+    exec(
+        &sql_context,
+        "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag => 
'tags_only', watermark => 1500)",
+    )
+    .await;
+    assert_eq!(read_tag(tmp.path(), "tags_only")["id"], 2);
+}
+
+#[tokio::test]
+async fn 
test_create_tag_from_watermark_equal_watermarks_prefer_earlier_snapshot() {
+    for (retained_id, expire_first) in [(1, true), (2, false)] {
+        let (tmp, sql_context) =
+            setup_table_with_watermarks([Some(1000), Some(1000), 
Some(2000)]).await;
+        exec(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag(table => 'test_db.t1', tag => 'keep', 
snapshot_id => {retained_id})"
+            ),
+        )
+        .await;
+        if expire_first {
+            
std::fs::remove_file(tmp.path().join("test_db.db/t1/snapshot/snapshot-1")).unwrap();
+        }
+        exec(
+            &sql_context,
+            "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag => 
'equal', watermark => 1000)",
+        )
+        .await;
+        assert_eq!(read_tag(tmp.path(), "equal")["id"], 1);
+    }
+}
+
+#[tokio::test]
+async fn 
test_create_tag_from_watermark_skips_missing_live_and_tag_watermarks() {
+    let (tmp, sql_context) = setup_table_with_watermarks([None, 
Some(i64::MIN), Some(2000)]).await;
+    for id in [1, 2] {
+        exec(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag(table => 'test_db.t1', tag => 
'missing_{id}', snapshot_id => {id})"
+            ),
+        )
+        .await;
+    }
+    exec(
+        &sql_context,
+        "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag => 
'live', watermark => 0)",
+    )
+    .await;
+    assert_eq!(read_tag(tmp.path(), "live")["id"], 3);
+    for id in [1, 2] {
+        std::fs::remove_file(
+            tmp.path()
+                .join(format!("test_db.db/t1/snapshot/snapshot-{id}")),
+        )
+        .unwrap();
+    }
+    for (index, watermark) in [i64::MIN, 0, 2000].into_iter().enumerate() {
+        let name = format!("valid_{index}");
+        exec(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag 
=> '{name}', watermark => {watermark})"
+            ),
+        )
+        .await;
+        assert_eq!(read_tag(tmp.path(), &name)["id"], 3);
+    }
+}
+
+#[tokio::test]
+async fn test_create_tag_from_watermark_without_candidate_does_not_write() {
+    for (watermarks, target) in [
+        ([None, None, None], 0),
+        ([None, Some(i64::MIN), None], i64::MIN),
+        ([Some(1000), Some(2000), Some(3000)], 3001),
+    ] {
+        let (tmp, sql_context) = setup_table_with_watermarks(watermarks).await;
+        exec(
+            &sql_context,
+            "CALL sys.create_tag(table => 'test_db.t1', tag => 'keep', 
snapshot_id => 2)",
+        )
+        .await;
+        let keep_path = tmp.path().join("test_db.db/t1/tag/tag-keep");
+        let original = std::fs::read(&keep_path).unwrap();
+        assert_sql_error(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag 
=> 'missing', watermark => {target})"
+            ),
+            &format!("No snapshot found with watermark >= {target}"),
+        )
+        .await;
+        assert!(!tmp.path().join("test_db.db/t1/tag/tag-missing").exists());
+        assert_eq!(std::fs::read(keep_path).unwrap(), original);
+        assert_eq!(
+            row_count(&sql_context, "SELECT * FROM 
paimon.test_db.`t1$tags`").await,
+            1
+        );
+    }
+
+    let (tmp, sql_context) = setup_sql_context().await;
+    exec(&sql_context, "CREATE TABLE paimon.test_db.t1 (id INT)").await;
+    assert_sql_error(
+        &sql_context,
+        "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag => 
'empty', watermark => 0)",
+        "No snapshot found with watermark >= 0",
+    )
+    .await;
+    assert!(!tmp.path().join("test_db.db/t1/tag").exists());
+}
+
+#[tokio::test]
+async fn 
test_create_tag_from_watermark_rejects_invalid_arguments_without_writes() {
+    let (tmp, sql_context) =
+        setup_table_with_watermarks([Some(1000), Some(2000), 
Some(3000)]).await;
+    exec(
+        &sql_context,
+        "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag => 
'keep', watermark => 1000)",
+    )
+    .await;
+    let keep_path = tmp.path().join("test_db.db/t1/tag/tag-keep");
+    let original = std::fs::read(&keep_path).unwrap();
+
+    for (args, expected_error) in [
+        ("tag => 'invalid', watermark => 1000", "Missing required argument: 
'table'"),
+        ("table => 'test_db.t1', watermark => 1000", "Missing required 
argument: 'tag'"),
+        ("table => 'test_db.t1', tag => 'invalid'", "Missing required 
argument: 'watermark'"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 'abc'", 
"Invalid watermark"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 1.5", "Invalid 
watermark"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 
9223372036854775808", "Invalid watermark"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 
-9223372036854775809", "Invalid watermark"),
+        ("table => 'test_db.t1', tag => 'invalid', watermak => 1000", 
"Argument watermak is unknown"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 1000, 
time_retain => '1d'", "Argument time_retain is unknown"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 1000, 
watermark => 2000", "Procedure argument watermark is duplicated"),
+        ("table => 'test_db.t1', tag => '', watermark => 1000", "Invalid tag 
name"),
+        ("table => 'test_db.t1', tag => '../invalid', watermark => 1000", 
"Invalid tag name"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 1000, 
time_retained => ''", "Invalid tag retention"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 1000, 
time_retained => 'invalid'", "Invalid tag retention"),
+        ("table => 'test_db.t1', tag => 'invalid', watermark => 1000, 
time_retained => '-1d'", "Invalid tag retention"),
+    ] {
+        assert_sql_error(
+            &sql_context,
+            &format!("CALL sys.create_tag_from_watermark({args})"),
+            expected_error,
+        )
+        .await;
+        assert_eq!(std::fs::read(&keep_path).unwrap(), original);
+    }
+    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,
+        1
+    );
+}
+
+#[tokio::test]
+async fn test_create_tag_from_watermark_preserves_existing_tag() {
+    let (tmp, sql_context) =
+        setup_table_with_watermarks([Some(1000), Some(2000), 
Some(3000)]).await;
+    exec(
+        &sql_context,
+        "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag => 
'keep', watermark => 1000, time_retained => '1d')",
+    )
+    .await;
+    let keep_path = tmp.path().join("test_db.db/t1/tag/tag-keep");
+    let original = std::fs::read(&keep_path).unwrap();
+    for (watermark, retention) in [(2000, "2d"), (2000, "invalid"), (4000, 
"1d")] {
+        assert_sql_error(
+            &sql_context,
+            &format!(
+                "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag 
=> 'keep', \
+                 watermark => {watermark}, time_retained => '{retention}')"
+            ),
+            "Tag 'keep' already exists",
+        )
+        .await;
+        assert_eq!(std::fs::read(&keep_path).unwrap(), original);
+    }
+}
+
 #[tokio::test]
 async fn test_rollback_cleans_newer_tags() {
     let (_tmp, sql_context) = setup_table_with_snapshots().await;
diff --git a/crates/integrations/datafusion/tests/rest_management_procedures.rs 
b/crates/integrations/datafusion/tests/rest_management_procedures.rs
index d38efa9d..039c2bfc 100644
--- a/crates/integrations/datafusion/tests/rest_management_procedures.rs
+++ b/crates/integrations/datafusion/tests/rest_management_procedures.rs
@@ -680,6 +680,151 @@ async fn test_rename_branch_rejected_on_rest_catalog() {
     .await;
 }
 
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_create_tag_from_watermark_rejects_readable_rest_catalog_table() {
+    use arrow_array::Int32Array;
+    use axum::http::{Method, StatusCode, Uri};
+    use axum::{Json, Router};
+    use paimon::catalog::Identifier;
+    use paimon::Catalog;
+    use std::sync::Mutex;
+
+    let (tmp, catalog) = common::create_test_env();
+    let writer = common::create_sql_context(catalog.clone()).await;
+    common::exec(&writer, "CREATE SCHEMA paimon.test_db").await;
+    common::exec(&writer, "CREATE TABLE paimon.test_db.t1 (id INT)").await;
+    let table_dir = tmp.path().join("test_db.db/t1");
+    for id in [1, 2] {
+        common::exec(
+            &writer,
+            &format!("INSERT INTO paimon.test_db.t1 VALUES ({id})"),
+        )
+        .await;
+        let path = table_dir.join(format!("snapshot/snapshot-{id}"));
+        let mut snapshot: serde_json::Value =
+            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+        snapshot["watermark"] = serde_json::json!(id * 1000);
+        std::fs::write(path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
+    }
+    common::exec(
+        &writer,
+        "CALL sys.create_tag(table => 'test_db.t1', tag => 'keep', snapshot_id 
=> 1, time_retained => '1d')",
+    )
+    .await;
+    let tag_path = table_dir.join("tag/tag-keep");
+    let original_tag = std::fs::read(&tag_path).unwrap();
+    let table = catalog
+        .get_table(&Identifier::new("test_db", "t1"))
+        .await
+        .unwrap();
+    assert_eq!(table.schema().core_options().file_format(), "parquet");
+    let snapshot = table.snapshot_manager().get_snapshot(2).await.unwrap();
+    let snapshot_response = serde_json::json!({"snapshot": {"snapshot": 
snapshot}});
+    let table_response = serde_json::json!({
+        "id": "table-uuid", "database": "test_db", "name": "t1",
+        "path": table.location(), "isExternal": false,
+        "schemaId": table.schema().id(), "schema": table.schema(),
+    });
+    // Only the catalog serves snapshot 2; the physical snapshot directory is 
stale.
+    std::fs::remove_file(table_dir.join("snapshot/snapshot-2")).unwrap();
+    assert_eq!(table.snapshot_manager().list_all_ids().await.unwrap(), [1]);
+
+    let requests = Arc::new(Mutex::new(Vec::new()));
+    let handler_requests = requests.clone();
+    let table_path = "/v1/test/databases/test_db/tables/t1";
+    let snapshot_path = format!("{table_path}/snapshot");
+    let app = Router::new().fallback(move |method: Method, uri: Uri| {
+        handler_requests
+            .lock()
+            .unwrap()
+            .push((method, uri.path().to_string()));
+        let (status, body) = match uri.path() {
+            "/v1/config" => (
+                StatusCode::OK,
+                serde_json::json!({"defaults": {"prefix": "test"}}),
+            ),
+            "/v1/test/databases/test_db" => (
+                StatusCode::OK,
+                serde_json::json!({"name": "test_db", "options": {}}),
+            ),
+            path if path == table_path => (StatusCode::OK, 
table_response.clone()),
+            path if path == snapshot_path => (StatusCode::OK, 
snapshot_response.clone()),
+            _ => (
+                StatusCode::NOT_FOUND,
+                serde_json::json!({"code": 404, "message": "Unexpected 
request"}),
+            ),
+        };
+        async move { (status, Json(body)) }
+    });
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let mut options = Options::new();
+    options.set(
+        CatalogOptions::URI,
+        format!("http://{}";, listener.local_addr().unwrap()),
+    );
+    options.set(CatalogOptions::WAREHOUSE, "test_warehouse");
+    options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+    options.set(CatalogOptions::TOKEN, "test-token");
+    let server = tokio::spawn(async move { axum::serve(listener, 
app).await.unwrap() });
+    let catalog = Arc::new(RESTCatalog::new(options, true).await.unwrap());
+    let mut context = SQLContext::new();
+    context
+        .register_catalog_with_default_db("paimon", catalog, None)
+        .await
+        .unwrap();
+
+    let batches = context
+        .sql("SELECT id FROM paimon.test_db.t1 ORDER BY id")
+        .await
+        .unwrap_or_else(|error| panic!("{error}; requests: {:?}", 
requests.lock().unwrap()))
+        .collect()
+        .await
+        .unwrap();
+    let ids: Vec<_> = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect();
+    assert_eq!(ids, [1, 2]);
+    assert!(requests
+        .lock()
+        .unwrap()
+        .iter()
+        .any(|(_, path)| path.ends_with("/snapshot")));
+    requests.lock().unwrap().clear();
+
+    for (watermark, tag) in [(2000, "checkpoint"), (1000, "older"), (1000, 
"keep")] {
+        let error = context.sql(&format!(
+            "CALL sys.create_tag_from_watermark(table => 'test_db.t1', tag => 
'{tag}', watermark => {watermark})"
+        )).await.unwrap_err();
+        assert!(
+            matches!(error, 
datafusion::error::DataFusionError::NotImplemented(_)),
+            "{error}"
+        );
+        assert!(
+            error.to_string().contains(
+                "create_tag_from_watermark is not supported for tables managed 
by a REST catalog"
+            ),
+            "{error}"
+        );
+    }
+    assert_eq!(std::fs::read(tag_path).unwrap(), original_tag);
+    assert_eq!(std::fs::read_dir(table_dir.join("tag")).unwrap().count(), 1);
+    let requests = requests.lock().unwrap();
+    assert!(!requests.is_empty());
+    assert!(requests
+        .iter()
+        .all(|(method, path)| method == Method::GET && path == table_path));
+    server.abort();
+}
+
 #[tokio::test]
 async fn test_delete_branch_rejected_on_rest_catalog() {
     // A REST catalog owns branch metadata and the Rust REST catalog has no
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 120b8a33..01c6f62f 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1080,12 +1080,45 @@ 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):
+Create a named tag from a commit timestamp (finds the earliest retained 
snapshot at or after the given time):
 
 ```sql
 CALL sys.create_tag_from_timestamp(table => 'paimon.my_db.my_table', tag => 
'my_tag', timestamp => 1234567890000);
 ```
 
+### create_tag_from_watermark
+
+This procedure is not supported for tables managed by a REST catalog. Their
+snapshots may exist only in the catalog, while watermark selection currently
+relies on stored snapshot files. REST-managed tables are rejected before
+candidate selection or tag creation.
+
+Create a named tag from the earliest retained snapshot whose watermark is 
greater
+than or equal to the requested value. Candidates include live snapshots and
+historical snapshots preserved by existing tags. Equal watermarks select the
+earlier snapshot ID; snapshots without a watermark, including the no-watermark
+sentinel `-9223372036854775808`, are skipped.
+
+```sql
+CALL sys.create_tag_from_watermark(
+    table => 'paimon.my_db.my_table',
+    tag => 'business_checkpoint',
+    watermark => 1704067200000,
+    time_retained => '7d'
+);
+```
+
+`table`, `tag`, and `watermark` are required. `watermark` is a signed 64-bit 
integer
+in milliseconds. It represents the input processing progress recorded by the
+writer, whereas `timestamp` in `create_tag_from_timestamp` refers to snapshot
+commit time. Selecting by watermark fixes an entire snapshot; it does not 
filter
+rows by event time.
+
+The optional `time_retained` uses the same duration format and metadata as
+`create_tag`. This procedure does not automatically delete expired tags. An
+existing tag name, an invalid argument, or no matching snapshot causes an error
+without creating or replacing a tag. The call does not wait for a future 
snapshot.
+
 ### delete_tag
 
 Delete a named tag:

Reply via email to