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 53dfa2cb [core] Support REST catalog version management (#1142)
53dfa2cb is described below

commit 53dfa2cb5e18141cb5f4bf1b37c1494a950ec00b
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 13:32:46 2026 +0800

    [core] Support REST catalog version management (#1142)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  32 ++
 bindings/python/src/catalog_version.rs             |  97 ++++++
 bindings/python/src/context.rs                     | 115 ++++++-
 bindings/python/src/error.rs                       |  13 +
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/tests/test_catalog_versions.py     | 172 ++++++++++
 crates/integrations/datafusion/src/procedures.rs   |  21 +-
 crates/paimon/src/api/api_request.rs               |  11 +
 crates/paimon/src/api/api_response.rs              |   8 +
 crates/paimon/src/api/mod.rs                       |   4 +-
 crates/paimon/src/api/rest_api.rs                  | 117 ++++---
 crates/paimon/src/catalog/mod.rs                   |  36 +++
 crates/paimon/src/catalog/rest/rest_catalog.rs     | 181 +++++++++--
 crates/paimon/src/spec/snapshot.rs                 |  16 +
 .../lib.rs => crates/paimon/src/table/instant.rs   |  64 ++--
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/tests/mock_server.rs                 |   8 +-
 crates/paimon/tests/rest_branch_test.rs            | 356 ++++++++++++++++++++-
 18 files changed, 1131 insertions(+), 123 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 44cee590..8c1bc75b 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -755,6 +755,27 @@ class CatalogError(ValueError):
     resource_name: Optional[str]
     message: str
 
+class Instant:
+    @staticmethod
+    def snapshot(snapshot_id: int) -> "Instant": ...
+    @staticmethod
+    def tag(tag_name: str) -> "Instant": ...
+
+class GetTagResponse:
+    def tag_name(self) -> str: ...
+    def snapshot(self) -> Snapshot: ...
+    def tag_create_time(self) -> Optional[int]: ...
+    def tag_time_retained(self) -> Optional[str]: ...
+    def to_json(self) -> str: ...
+
+class TableSnapshot:
+    def snapshot(self) -> Snapshot: ...
+    def record_count(self) -> Optional[int]: ...
+    def file_size_in_bytes(self) -> Optional[int]: ...
+    def file_count(self) -> Optional[int]: ...
+    def last_file_creation_time(self) -> Optional[int]: ...
+    def to_json(self) -> str: ...
+
 class PaimonCatalog:
     def __init__(self, catalog_options: Dict[str, str], *, config_required: 
bool = True) -> None:
         """Set config_required=False when the REST configuration has already 
been resolved."""
@@ -772,6 +793,17 @@ class PaimonCatalog:
         """Replace the identifier's current branch metadata through the REST 
server."""
         ...
     def list_branches(self, identifier: Union[str, Tuple[str, str]]) -> 
List[str]: ...
+    def create_tag(self, identifier: Union[str, Tuple[str, str]], tag_name: 
str,
+                   snapshot_id: Optional[int] = None, time_retained: 
Optional[str] = None,
+                   ignore_if_exists: bool = False) -> None: ...
+    def get_tag(self, identifier: Union[str, Tuple[str, str]], tag_name: str) 
-> GetTagResponse: ...
+    def list_tags_paged(self, identifier: Union[str, Tuple[str, str]], 
max_results: Optional[int] = None,
+                       page_token: Optional[str] = None,
+                       tag_name_prefix: Optional[str] = None) -> 
Tuple[List[str], Optional[str]]: ...
+    def delete_tag(self, identifier: Union[str, Tuple[str, str]], tag_name: 
str) -> None: ...
+    def load_snapshot(self, identifier: Union[str, Tuple[str, str]]) -> 
Optional[TableSnapshot]: ...
+    def rollback_to(self, identifier: Union[str, Tuple[str, str]], instant: 
Instant,
+                    from_snapshot: Optional[int] = None, *, table_uuid: 
Optional[str] = None) -> None: ...
 
 class PythonScalarUDF:
     def __init__(
diff --git a/bindings/python/src/catalog_version.rs 
b/bindings/python/src/catalog_version.rs
new file mode 100644
index 00000000..ba92f1fe
--- /dev/null
+++ b/bindings/python/src/catalog_version.rs
@@ -0,0 +1,97 @@
+// 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 paimon::api::{GetTagResponse, TableSnapshot};
+use paimon::table::Instant;
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+
+use crate::snapshot::PySnapshot;
+
+/// Typed rollback target; serialization and rollback semantics belong to core.
+#[pyclass(name = "Instant", module = "pypaimon_rust.datafusion")]
+pub(crate) struct PyInstant {
+    pub(crate) inner: Instant,
+}
+
+#[pymethods]
+impl PyInstant {
+    #[staticmethod]
+    fn snapshot(snapshot_id: i64) -> Self {
+        Self {
+            inner: Instant::snapshot(snapshot_id),
+        }
+    }
+
+    #[staticmethod]
+    fn tag(tag_name: String) -> Self {
+        Self {
+            inner: Instant::tag(tag_name),
+        }
+    }
+}
+
+#[pyclass(name = "GetTagResponse", module = "pypaimon_rust.datafusion")]
+pub(crate) struct PyGetTagResponse {
+    pub(crate) inner: GetTagResponse,
+}
+
+#[pymethods]
+impl PyGetTagResponse {
+    fn tag_name(&self) -> &str {
+        &self.inner.tag_name
+    }
+    fn snapshot(&self) -> PySnapshot {
+        PySnapshot::new(self.inner.snapshot.clone())
+    }
+    fn tag_create_time(&self) -> Option<i64> {
+        self.inner.tag_create_time
+    }
+    fn tag_time_retained(&self) -> Option<&str> {
+        self.inner.tag_time_retained.as_deref()
+    }
+    fn to_json(&self) -> PyResult<String> {
+        serde_json::to_string(&self.inner).map_err(|error| 
PyValueError::new_err(error.to_string()))
+    }
+}
+
+#[pyclass(name = "TableSnapshot", module = "pypaimon_rust.datafusion")]
+pub(crate) struct PyTableSnapshot {
+    pub(crate) inner: TableSnapshot,
+}
+
+#[pymethods]
+impl PyTableSnapshot {
+    fn snapshot(&self) -> PySnapshot {
+        PySnapshot::new(self.inner.snapshot.clone())
+    }
+    fn record_count(&self) -> Option<i64> {
+        self.inner.record_count
+    }
+    fn file_size_in_bytes(&self) -> Option<i64> {
+        self.inner.file_size_in_bytes
+    }
+    fn file_count(&self) -> Option<i64> {
+        self.inner.file_count
+    }
+    fn last_file_creation_time(&self) -> Option<i64> {
+        self.inner.last_file_creation_time
+    }
+    fn to_json(&self) -> PyResult<String> {
+        serde_json::to_string(&self.inner).map_err(|error| 
PyValueError::new_err(error.to_string()))
+    }
+}
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index d7223350..170b3eab 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -41,6 +41,7 @@ use pyo3::prelude::*;
 use pyo3::types::PyCapsule;
 
 use crate::blob::PyBlobReaderRegistry;
+use crate::catalog_version::{PyGetTagResponse, PyInstant, PyTableSnapshot};
 use crate::error::{df_to_py_err, to_catalog_py_err, to_py_err};
 use crate::read::PyRecordBatchReader;
 use crate::table::PyTable;
@@ -292,7 +293,7 @@ pub struct PaimonCatalog {
     provider: Arc<PaimonCatalogProvider>,
 }
 
-fn catalog_table_identifier(identifier: &Bound<'_, PyAny>) -> 
PyResult<Identifier> {
+fn catalog_identifier(identifier: &Bound<'_, PyAny>) -> PyResult<Identifier> {
     let (database, object_name) = if let Ok(name) = 
identifier.extract::<String>() {
         let (database, table) = name.split_once('.').ok_or_else(|| {
             PyValueError::new_err(format!(
@@ -308,7 +309,11 @@ fn catalog_table_identifier(identifier: &Bound<'_, PyAny>) 
-> PyResult<Identifie
             "database and table names must not be empty",
         ));
     }
-    let identifier = Identifier::new(&database, &object_name);
+    Ok(Identifier::new(&database, &object_name))
+}
+
+fn catalog_table_identifier(identifier: &Bound<'_, PyAny>) -> 
PyResult<Identifier> {
+    let identifier = catalog_identifier(identifier)?;
     if identifier
         .parsed_object_name()
         .map_err(to_py_err)?
@@ -455,6 +460,109 @@ impl PaimonCatalog {
         py.detach(|| 
runtime().block_on(self.catalog.list_branches(&identifier)))
             .map_err(to_catalog_py_err)
     }
+
+    /// Create a tag through core catalog handling, including optional 
retention.
+    #[pyo3(signature = (identifier, tag_name, snapshot_id=None, 
time_retained=None, ignore_if_exists=false))]
+    fn create_tag(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        tag_name: &str,
+        snapshot_id: Option<i64>,
+        time_retained: Option<&str>,
+        ignore_if_exists: bool,
+    ) -> PyResult<()> {
+        let identifier = catalog_identifier(identifier)?;
+        py.detach(|| {
+            runtime().block_on(self.catalog.create_tag_with_retention(
+                &identifier,
+                tag_name,
+                snapshot_id,
+                time_retained,
+                ignore_if_exists,
+            ))
+        })
+        .map_err(to_catalog_py_err)
+    }
+
+    fn get_tag(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        tag_name: &str,
+    ) -> PyResult<PyGetTagResponse> {
+        let identifier = catalog_identifier(identifier)?;
+        let inner = py
+            .detach(|| runtime().block_on(self.catalog.get_tag(&identifier, 
tag_name)))
+            .map_err(to_catalog_py_err)?;
+        Ok(PyGetTagResponse { inner })
+    }
+
+    #[pyo3(signature = (identifier, max_results=None, page_token=None, 
tag_name_prefix=None))]
+    fn list_tags_paged(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        max_results: Option<i32>,
+        page_token: Option<&str>,
+        tag_name_prefix: Option<&str>,
+    ) -> PyResult<(Vec<String>, Option<String>)> {
+        let identifier = catalog_identifier(identifier)?;
+        let page = py
+            .detach(|| {
+                runtime().block_on(self.catalog.list_tags_paged(
+                    &identifier,
+                    max_results,
+                    page_token,
+                    tag_name_prefix,
+                ))
+            })
+            .map_err(to_catalog_py_err)?;
+        Ok((page.elements, page.next_page_token))
+    }
+
+    fn delete_tag(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        tag_name: &str,
+    ) -> PyResult<()> {
+        let identifier = catalog_identifier(identifier)?;
+        py.detach(|| runtime().block_on(self.catalog.delete_tag(&identifier, 
tag_name, false)))
+            .map_err(to_catalog_py_err)
+    }
+
+    fn load_snapshot(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+    ) -> PyResult<Option<PyTableSnapshot>> {
+        let identifier = catalog_identifier(identifier)?;
+        py.detach(|| 
runtime().block_on(self.catalog.load_snapshot(&identifier)))
+            .map(|snapshot| snapshot.map(|inner| PyTableSnapshot { inner }))
+            .map_err(to_catalog_py_err)
+    }
+
+    #[pyo3(signature = (identifier, instant, from_snapshot=None, *, 
table_uuid=None))]
+    fn rollback_to(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        instant: &PyInstant,
+        from_snapshot: Option<i64>,
+        table_uuid: Option<&str>,
+    ) -> PyResult<()> {
+        let identifier = catalog_identifier(identifier)?;
+        py.detach(|| {
+            runtime().block_on(self.catalog.rollback_to(
+                &identifier,
+                &instant.inner,
+                from_snapshot,
+                table_uuid,
+            ))
+        })
+        .map_err(to_catalog_py_err)
+    }
 }
 
 #[pyclass(name = "SQLOptions", module = "pypaimon_rust.datafusion")]
@@ -879,6 +987,9 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     let this = PyModule::new(py, "datafusion")?;
     this.add("CatalogError", py.get_type::<crate::error::CatalogError>())?;
     this.add_class::<PaimonCatalog>()?;
+    this.add_class::<PyInstant>()?;
+    this.add_class::<PyGetTagResponse>()?;
+    this.add_class::<PyTableSnapshot>()?;
     this.add_class::<crate::table::PyTable>()?;
     this.add_class::<crate::system_table::PySystemRead>()?;
     this.add_class::<crate::index_build::PyGlobalIndexBuildBuilder>()?;
diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs
index b94999cb..4e9b277a 100644
--- a/bindings/python/src/error.rs
+++ b/bindings/python/src/error.rs
@@ -45,6 +45,10 @@ pub(crate) fn to_catalog_py_err(error: paimon::Error) -> 
PyErr {
     if error.is_process_fork_unsupported() {
         return to_py_err(error);
     }
+    let snapshot_name = match &error {
+        Error::SnapshotNotExist { snapshot_id } => 
Some(snapshot_id.to_string()),
+        _ => None,
+    };
     let (code, resource_type, resource_name, message) = match &error {
         Error::BranchAlreadyExist { branch_name, .. } => (
             409,
@@ -67,6 +71,15 @@ pub(crate) fn to_catalog_py_err(error: paimon::Error) -> 
PyErr {
         Error::TagNotExist { tag_name } => {
             (404, Some("TAG"), Some(tag_name.as_str()), error.to_string())
         }
+        Error::TagAlreadyExist { tag_name } => {
+            (409, Some("TAG"), Some(tag_name.as_str()), error.to_string())
+        }
+        Error::SnapshotNotExist { .. } => (
+            404,
+            Some("SNAPSHOT"),
+            snapshot_name.as_deref(),
+            error.to_string(),
+        ),
         Error::DataInvalid { message, .. } => (400, None, None, 
message.clone()),
         Error::RestApi { source } => {
             let (code, resource_type, resource_name, message) = match source {
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 0b4508c8..72d17d42 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -19,6 +19,7 @@ use pyo3::prelude::*;
 
 mod blob;
 mod blob_uri_reader;
+mod catalog_version;
 mod commit_callback;
 mod context;
 mod error;
diff --git a/bindings/python/tests/test_catalog_versions.py 
b/bindings/python/tests/test_catalog_versions.py
new file mode 100644
index 00000000..47760084
--- /dev/null
+++ b/bindings/python/tests/test_catalog_versions.py
@@ -0,0 +1,172 @@
+# 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.
+
+"""REST version management exposes typed core operations without Python 
work."""
+
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import json
+import threading
+from urllib.parse import parse_qs, urlsplit
+
+import pytest
+
+from pypaimon_rust.datafusion import CatalogError, Instant, PaimonCatalog
+
+
+SNAPSHOT = dict(version=3, id=7, schemaId=2, baseManifestList="base", 
deltaManifestList="delta",
+                commitUser="writer", commitIdentifier=1, commitKind="APPEND", 
timeMillis=123,
+                uuid="snapshot-uuid", totalRecordCount=8, 
properties={"source": "test"},
+                baseManifestListSize=321, deltaManifestListSize=0)
+
+
[email protected]
+def transport():
+    class Handler(BaseHTTPRequestHandler):
+        def operation(self):
+            size = int(self.headers.get("Content-Length", 0))
+            body = json.loads(self.rfile.read(size)) if size else None
+            self.server.requests.append((self.command, self.path, body))
+            assert self.headers["Authorization"] == "Bearer token"
+            data = self.server.body
+            if data is None and self.server.status == 200:
+                path = urlsplit(self.path).path
+                if path.endswith("/snapshot"):
+                    data = {"snapshot": dict(snapshot=SNAPSHOT, recordCount=0, 
fileCount=2,
+                                             fileSizeInBytes=123, 
lastFileCreationTime=456)}
+                elif path.endswith("/tags"):
+                    data = {"tags": ["z", "a", "z"], "nextPageToken": "next 
+/%一"}
+                elif "/tags/" in path:
+                    data = dict(tagName="release", snapshot=SNAPSHOT, 
tagCreateTime=123,
+                                tagTimeRetained="2 d")
+            output = data.encode() if isinstance(data, str) else 
json.dumps(data).encode()
+            self.send_response(self.server.status)
+            self.send_header("Content-Length", str(len(output)))
+            self.end_headers()
+            self.wfile.write(output)
+
+        do_GET = do_POST = do_DELETE = operation
+
+        def log_message(self, *args):
+            pass
+
+    server = HTTPServer(("127.0.0.1", 0), Handler)
+    server.requests, server.status, server.body = [], 200, None
+    thread = threading.Thread(target=server.serve_forever)
+    thread.start()
+    try:
+        yield server, PaimonCatalog({
+            "metastore": "rest", "uri": "http://127.0.0.1:%d"; % 
server.server_port,
+            "warehouse": "w", "prefix": "tenant", "token.provider": "bear", 
"token": "token",
+        }, config_required=False)
+    finally:
+        server.shutdown()
+        server.server_close()
+        thread.join()
+
+
+def test_typed_metadata_retains_snapshot_and_catalog_statistics(transport):
+    server, native = transport
+    snapshot = native.load_snapshot(("db.name", "table$branch_dev /一"))
+    assert snapshot.snapshot().id() == 7
+    assert snapshot.snapshot().uuid() == "snapshot-uuid"
+    assert snapshot.snapshot().properties() == {"source": "test"}
+    assert (snapshot.record_count(), snapshot.file_count(), 
snapshot.file_size_in_bytes(),
+            snapshot.last_file_creation_time()) == (0, 2, 123, 456)
+    assert json.loads(snapshot.to_json())["snapshot"]["baseManifestList"] == 
"base"
+    assert json.loads(snapshot.to_json())["snapshot"]["baseManifestListSize"] 
== 321
+    assert json.loads(snapshot.to_json())["snapshot"]["deltaManifestListSize"] 
== 0
+    tag = native.get_tag("db.table", "release")
+    assert tag.tag_name() == "release"
+    assert tag.snapshot().id() == 7
+    assert tag.tag_create_time() == 123
+    assert tag.tag_time_retained() == "2 d"
+    assert json.loads(tag.to_json())["snapshot"]["properties"] == {"source": 
"test"}
+    assert json.loads(tag.to_json())["snapshot"]["baseManifestListSize"] == 321
+    assert json.loads(tag.to_json())["snapshot"]["deltaManifestListSize"] == 0
+    assert len(server.requests) == 2
+
+
+def test_tag_page_uses_tuple_and_preserves_opaque_parameters(transport):
+    server, native = transport
+    page = native.list_tags_paged("db.table", 3, "next +/%一", "release +/%一")
+    assert page == (["z", "a", "z"], "next +/%一")
+    query = parse_qs(urlsplit(server.requests[0][1]).query)
+    assert query == {"maxResults": ["3"], "pageToken": ["next +/%一"], 
"tagNamePrefix": ["release +/%一"]}
+
+
[email protected]("instant,payload", [
+    (Instant.snapshot(7), {"type": "snapshot", "snapshotId": 7}),
+    (Instant.tag("release /一"), {"type": "tag", "tagName": "release /一"}),
+])
[email protected]("guard", [False, True])
+def test_rollback_instants_and_guards_are_forwarded(transport, instant, 
payload, guard):
+    server, native = transport
+    server.body = "OK"
+    kwargs = dict(from_snapshot=9, table_uuid="table-incarnation") if guard 
else {}
+    native.rollback_to(("db.name", "table$branch_dev /一"), instant, **kwargs)
+    expected = {"instant": payload}
+    if guard:
+        expected.update(fromSnapshot=9, tableId="table-incarnation")
+    assert len(server.requests) == 1
+    method, path, body = server.requests[0]
+    assert method == "POST"
+    assert path.endswith("/tables/table%24branch_dev+%2F%E4%B8%80/rollback")
+    assert body == expected
+
+
[email protected]("operation", ["create_tag", "delete_tag", 
"rollback_to"])
[email protected]("status", [400, 403, 409, 429, 500, 501, 503])
+def test_failed_mutations_are_not_replayed(transport, operation, status):
+    server, native = transport
+    server.status, server.body = status, {"message": "rejected"}
+    args = (Instant.snapshot(7),) if operation == "rollback_to" else 
("release",)
+    with pytest.raises(CatalogError) as caught:
+        getattr(native, operation)("db.table", *args)
+    assert caught.value.code == status
+    assert len(server.requests) == 1
+
+
[email protected]("operation,resource,name", [
+    ("create_tag", "SNAPSHOT", "7"), ("create_tag", "TAG", "release"),
+    ("get_tag", "TAG", "release"), ("delete_tag", "TAG", "release"),
+    ("rollback_to", "SNAPSHOT", "7"), ("rollback_to", "TAG", "release"),
+    ("rollback_to", "TABLE", "db.table"), ("list_tags_paged", "TABLE", 
"db.table"),
+])
+def test_structured_version_errors_preserve_missing_resource(transport, 
operation, resource, name):
+    server, native = transport
+    server.status = 409 if operation == "create_tag" and resource == "TAG" 
else 404
+    server.body = dict(resourceType=resource, resourceName=name, 
message="missing")
+    if operation == "rollback_to":
+        args = (Instant.snapshot(7),)
+    elif operation == "list_tags_paged":
+        args = ()
+    else:
+        args = ("release",)
+        if operation == "create_tag":
+            args += (7,)
+    with pytest.raises(CatalogError) as caught:
+        getattr(native, operation)("db.table", *args)
+    assert (caught.value.code, caught.value.resource_type, 
caught.value.resource_name) == (
+        server.status, resource, name)
+    assert len(server.requests) == 1
+
+
+def test_native_tag_ignore_existing_is_the_core_catalog_contract(transport):
+    server, native = transport
+    server.status, server.body = 409, {"resourceType": "TAG", "resourceName": 
"release"}
+    native.create_tag("db.table", "release", ignore_if_exists=True)
+    assert len(server.requests) == 1
diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index 0161536c..3124a863 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -635,13 +635,23 @@ async fn proc_rollback_to(
                 .map_err(|_| DataFusionError::Plan(format!("Invalid 
snapshot_id: '{id_str}'")))?;
             rest_env
                 .api()
-                .rollback_to_snapshot(rest_env.identifier(), id)
+                .rollback_to(
+                    rest_env.identifier(),
+                    &paimon::table::Instant::snapshot(id),
+                    None,
+                    Some(rest_env.uuid()),
+                )
                 .await
                 .map_err(to_datafusion_error)?;
         } else if let Some(tag_name) = args.get("tag") {
             rest_env
                 .api()
-                .rollback_to_tag(rest_env.identifier(), tag_name)
+                .rollback_to(
+                    rest_env.identifier(),
+                    &paimon::table::Instant::tag(tag_name),
+                    None,
+                    Some(rest_env.uuid()),
+                )
                 .await
                 .map_err(to_datafusion_error)?;
         } else {
@@ -723,7 +733,12 @@ async fn proc_rollback_to_timestamp(
     if let Some(rest_env) = table.rest_env() {
         rest_env
             .api()
-            .rollback_to_snapshot(rest_env.identifier(), snapshot.id())
+            .rollback_to(
+                rest_env.identifier(),
+                &paimon::table::Instant::snapshot(snapshot.id()),
+                None,
+                Some(rest_env.uuid()),
+            )
             .await
             .map_err(to_datafusion_error)?;
     } else {
diff --git a/crates/paimon/src/api/api_request.rs 
b/crates/paimon/src/api/api_request.rs
index b46300c3..132b11bd 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -87,6 +87,17 @@ pub struct CreateBranchRequest {
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct ForwardBranchRequest {}
 
+/// A rollback guarded by the observed table incarnation and latest snapshot.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RollbackTableRequest {
+    pub instant: crate::table::Instant,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub from_snapshot: Option<i64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub table_id: Option<String>,
+}
+
 /// Request to create a table tag.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
diff --git a/crates/paimon/src/api/api_response.rs 
b/crates/paimon/src/api/api_response.rs
index aa489f62..8863c597 100644
--- a/crates/paimon/src/api/api_response.rs
+++ b/crates/paimon/src/api/api_response.rs
@@ -170,6 +170,14 @@ pub struct GetTagResponse {
     pub tag_time_retained: Option<String>,
 }
 
+/// Tag names and an opaque continuation token supplied by the REST server.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListTagsResponse {
+    pub tags: Option<Vec<String>>,
+    pub next_page_token: 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 55527ae0..f21a1e6a 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -37,7 +37,7 @@ pub use api_request::{
     CreateDatabaseRequest, CreateFunctionRequest, CreatePartitionsRequest, 
CreateTableRequest,
     CreateTagRequest, CreateViewRequest, DropPartitionsRequest, 
DropPolicyRequest,
     ForwardBranchRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
-    PolicyRequest, RenameTableRequest, RevokePermissionRequest,
+    PolicyRequest, RenameTableRequest, RevokePermissionRequest, 
RollbackTableRequest,
 };
 
 // Re-export response types
@@ -46,7 +46,7 @@ pub use api_response::{
     GetFunctionResponse, GetTableResponse, GetTableSnapshotResponse, 
GetTableTokenResponse,
     GetTagResponse, GetViewResponse, ListBranchesResponse, 
ListDatabasesResponse,
     ListFunctionsResponse, ListPartitionsResponse, ListPermissionsResponse, 
ListPoliciesResponse,
-    ListTablesResponse, ListViewsResponse, PagedList, TableSnapshot,
+    ListTablesResponse, ListTagsResponse, ListViewsResponse, PagedList, 
TableSnapshot,
 };
 
 // Re-export management types
diff --git a/crates/paimon/src/api/rest_api.rs 
b/crates/paimon/src/api/rest_api.rs
index 45ec5a86..c7329a3b 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -33,13 +33,13 @@ use super::api_request::{
     CreateDatabaseRequest, CreateFunctionRequest, CreatePartitionsRequest, 
CreateTableRequest,
     CreateTagRequest, CreateViewRequest, DropPartitionsRequest, 
DropPolicyRequest,
     ForwardBranchRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
-    PolicyRequest, RenameTableRequest, RevokePermissionRequest,
+    PolicyRequest, RenameTableRequest, RevokePermissionRequest, 
RollbackTableRequest,
 };
 use super::api_response::{
     AuthTableQueryResponse, ConfigResponse, ErrorResponse, GetDatabaseResponse,
     GetFunctionResponse, GetTableResponse, GetTagResponse, GetViewResponse, 
ListBranchesResponse,
     ListDatabasesResponse, ListFunctionsResponse, ListPartitionsResponse, 
ListPermissionsResponse,
-    ListPoliciesResponse, ListTablesResponse, ListViewsResponse, PagedList,
+    ListPoliciesResponse, ListTablesResponse, ListTagsResponse, 
ListViewsResponse, PagedList,
 };
 use super::auth::{AuthProviderFactory, RESTAuthFunction};
 use super::management::{
@@ -420,16 +420,16 @@ impl RESTApi {
         &self,
         identifier: &Identifier,
     ) -> Result<Option<super::TableSnapshot>> {
-        validate_non_empty_multi(&[
+        validate_resource_path(&[
             (identifier.database(), "database name"),
             (identifier.object(), "table name"),
         ])?;
         let path = self
             .resource_paths
             .table_snapshot(identifier.database(), identifier.object());
-        let response: super::GetTableSnapshotResponse =
+        let response: Option<super::GetTableSnapshotResponse> =
             self.client.get(&path, None::<&[(&str, &str)]>).await?;
-        Ok(response.snapshot)
+        Ok(response.and_then(|response| response.snapshot))
     }
 
     /// Rename a table.
@@ -1067,22 +1067,18 @@ impl RESTApi {
     ) -> Result<()> {
         let database = identifier.database();
         let table = identifier.object();
-        validate_non_empty_multi(&[
-            (database, "database name"),
-            (table, "table name"),
-            (tag_name, "tag name"),
-        ])?;
+        validate_resource_path(&[(database, "database name"), (table, "table 
name")])?;
+        validate_non_empty(tag_name, "tag name")?;
         let path = self.resource_paths.tags(database, table);
         let mut request = CreateTagRequest::new(tag_name.to_string(), 
snapshot_id);
         request.time_retained = time_retained.map(str::to_string);
-        let _response: serde_json::Value = self.client.post(&path, 
&request).await?;
-        Ok(())
+        self.client.post_without_response(&path, &request).await
     }
 
     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(&[
+        validate_resource_path(&[
             (database, "database name"),
             (table, "table name"),
             (tag_name, "tag name"),
@@ -1091,18 +1087,50 @@ impl RESTApi {
         self.client.get(&path, None::<&[(&str, &str)]>).await
     }
 
+    /// Fetch one tag-name page, preserving server ordering and opaque tokens.
+    pub async fn list_tags_paged(
+        &self,
+        identifier: &Identifier,
+        max_results: Option<i32>,
+        page_token: Option<&str>,
+        tag_name_prefix: Option<&str>,
+    ) -> Result<PagedList<String>> {
+        validate_resource_path(&[
+            (identifier.database(), "database name"),
+            (identifier.object(), "table name"),
+        ])?;
+        let mut params = Vec::new();
+        if let Some(max_results) = max_results.filter(|value| *value > 0) {
+            params.push((Self::MAX_RESULTS, max_results.to_string()));
+        }
+        if let Some(page_token) = page_token {
+            params.push((Self::PAGE_TOKEN, page_token.to_string()));
+        }
+        if let Some(prefix) = tag_name_prefix.filter(|value| 
!value.is_empty()) {
+            params.push(("tagNamePrefix", prefix.to_string()));
+        }
+        let path = self
+            .resource_paths
+            .tags(identifier.database(), identifier.object());
+        let response: ListTagsResponse = self.client.get(&path, 
Some(&params)).await?;
+        // Java ends pagination when the response has no tags field, including
+        // explicit null, even if a server also returned a continuation token.
+        Ok(match response.tags {
+            Some(tags) => PagedList::new(tags, response.next_page_token),
+            None => PagedList::new(Vec::new(), None),
+        })
+    }
+
     pub async fn delete_tag(&self, identifier: &Identifier, tag_name: &str) -> 
Result<()> {
         let database = identifier.database();
         let table = identifier.object();
-        validate_non_empty_multi(&[
+        validate_resource_path(&[
             (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(())
+        self.client.delete_without_response(&path).await
     }
 
     // ==================== Policy Management ====================
@@ -1196,43 +1224,30 @@ impl RESTApi {
             .unwrap_or(false))
     }
 
-    /// Rollback a table to a specific snapshot.
-    pub async fn rollback_to_snapshot(
+    /// Roll back a table through the server, optionally comparing its identity
+    /// and current head. Omitted guards stay omitted on the wire, as in Java.
+    pub async fn rollback_to(
         &self,
         identifier: &Identifier,
-        snapshot_id: i64,
+        instant: &crate::table::Instant,
+        from_snapshot: Option<i64>,
+        table_uuid: Option<&str>,
     ) -> Result<()> {
-        let database = identifier.database();
-        let table = identifier.object();
-        validate_non_empty_multi(&[(database, "database name"), (table, "table 
name")])?;
-        let path = self.resource_paths.rollback(database, table);
-        let request = serde_json::json!({
-            "instant": {
-                "type": "snapshot",
-                "snapshotId": snapshot_id,
-            }
-        });
-        let _resp: serde_json::Value = self.client.post(&path, 
&request).await?;
-        Ok(())
-    }
-
-    /// Rollback a table to a specific tag.
-    pub async fn rollback_to_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"),
+        validate_resource_path(&[
+            (identifier.database(), "database name"),
+            (identifier.object(), "table name"),
         ])?;
-        let path = self.resource_paths.rollback(database, table);
-        let request = serde_json::json!({
-            "instant": {
-                "type": "tag",
-                "tagName": tag_name,
-            }
-        });
-        let _resp: serde_json::Value = self.client.post(&path, 
&request).await?;
-        Ok(())
+        if let crate::table::Instant::Tag { tag_name } = instant {
+            validate_non_empty(tag_name, "tag name")?;
+        }
+        let request = RollbackTableRequest {
+            instant: instant.clone(),
+            from_snapshot,
+            table_id: table_uuid.map(str::to_string),
+        };
+        let path = self
+            .resource_paths
+            .rollback(identifier.database(), identifier.object());
+        self.client.post_without_response(&path, &request).await
     }
 }
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 12e73a84..3c5923b1 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -570,6 +570,42 @@ pub trait Catalog: Send + Sync {
         })
     }
 
+    /// Load the catalog's current snapshot and statistics, or None for an 
empty table.
+    async fn load_snapshot(
+        &self,
+        _identifier: &Identifier,
+    ) -> Result<Option<crate::api::TableSnapshot>> {
+        Err(Error::Unsupported {
+            message: "catalog snapshot loading is not supported by this 
catalog".to_string(),
+        })
+    }
+
+    /// Roll back through the catalog, forwarding optional expected head and 
table identity.
+    async fn rollback_to(
+        &self,
+        _identifier: &Identifier,
+        _instant: &crate::table::Instant,
+        _from_snapshot: Option<i64>,
+        _table_uuid: Option<&str>,
+    ) -> Result<()> {
+        Err(Error::Unsupported {
+            message: "catalog rollback is not supported by this 
catalog".to_string(),
+        })
+    }
+
+    /// Fetch a page of tag names without scanning tag files on storage.
+    async fn list_tags_paged(
+        &self,
+        _identifier: &Identifier,
+        _max_results: Option<i32>,
+        _page_token: Option<&str>,
+        _tag_name_prefix: Option<&str>,
+    ) -> Result<PagedList<String>> {
+        Err(Error::Unsupported {
+            message: "tag pagination is not supported by this 
catalog".to_string(),
+        })
+    }
+
     // ======================= branch methods =============================
 
     /// Create an empty branch, or create one from a tag when `from_tag` is 
set.
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 3f4f623d..93875d28 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -408,6 +408,44 @@ impl Catalog for RESTCatalog {
         })
     }
 
+    async fn load_snapshot(
+        &self,
+        identifier: &Identifier,
+    ) -> Result<Option<crate::api::TableSnapshot>> {
+        match self.api.load_snapshot(identifier).await {
+            Err(Error::RestApi {
+                source: RestError::NoSuchResource { resource_type, .. },
+            }) if resource_type.as_deref() == Some("SNAPSHOT") => Ok(None),
+            result => result.map_err(|error| map_table_not_exist(error, 
identifier)),
+        }
+    }
+
+    async fn rollback_to(
+        &self,
+        identifier: &Identifier,
+        instant: &crate::table::Instant,
+        from_snapshot: Option<i64>,
+        table_uuid: Option<&str>,
+    ) -> Result<()> {
+        self.api
+            .rollback_to(identifier, instant, from_snapshot, table_uuid)
+            .await
+            .map_err(|error| map_rest_error_for_rollback(error, identifier, 
instant))
+    }
+
+    async fn list_tags_paged(
+        &self,
+        identifier: &Identifier,
+        max_results: Option<i32>,
+        page_token: Option<&str>,
+        tag_name_prefix: Option<&str>,
+    ) -> Result<PagedList<String>> {
+        self.api
+            .list_tags_paged(identifier, max_results, page_token, 
tag_name_prefix)
+            .await
+            .map_err(|error| map_table_not_exist(error, identifier))
+    }
+
     async fn create_tag(
         &self,
         identifier: &Identifier,
@@ -874,6 +912,60 @@ fn map_rest_error_for_table(err: Error, identifier: 
&Identifier) -> Error {
     }
 }
 
+fn map_table_not_exist(error: Error, identifier: &Identifier) -> Error {
+    match error {
+        Error::RestApi {
+            source: RestError::NoSuchResource { .. },
+        } => Error::TableNotExist {
+            full_name: identifier.full_name(),
+        },
+        other => other,
+    }
+}
+
+fn map_rest_error_for_rollback(
+    error: Error,
+    identifier: &Identifier,
+    instant: &crate::table::Instant,
+) -> Error {
+    if let Error::RestApi {
+        source:
+            RestError::NoSuchResource {
+                resource_type,
+                resource_name,
+                ..
+            },
+    } = &error
+    {
+        if resource_type.as_deref() == Some("SNAPSHOT") {
+            let snapshot_id = resource_name
+                .as_deref()
+                .and_then(|name| name.parse().ok())
+                .or(match instant {
+                    crate::table::Instant::Snapshot { snapshot_id } => 
Some(*snapshot_id),
+                    _ => None,
+                });
+            return snapshot_id
+                .map(|snapshot_id| Error::SnapshotNotExist { snapshot_id })
+                .unwrap_or(error);
+        }
+        if resource_type.as_deref() == Some("TAG") {
+            let tag_name = resource_name
+                .clone()
+                .or_else(|| match instant {
+                    crate::table::Instant::Tag { tag_name } => 
Some(tag_name.clone()),
+                    _ => None,
+                })
+                .unwrap_or_default();
+            return Error::TagNotExist { tag_name };
+        }
+        return Error::TableNotExist {
+            full_name: identifier.full_name(),
+        };
+    }
+    error
+}
+
 fn map_rest_error_for_tag(
     error: Error,
     identifier: &Identifier,
@@ -891,32 +983,29 @@ fn map_rest_error_for_tag(
                 RestError::NoSuchResource {
                     resource_type,
                     resource_name,
-                    ..
+                    message,
                 },
-        } 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())) {
+        } if resource_type.as_deref() == Some("SNAPSHOT") => {
+            match snapshot_id.or_else(|| {
+                resource_name
+                    .as_deref()
+                    .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,
+                None => Error::RestApi {
+                    source: RestError::NoSuchResource {
+                        resource_type,
+                        resource_name,
+                        message,
+                    },
                 },
             }
         }
         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(),
-            }
-        }
+        } if resource_type.as_deref() == Some("TAG") => Error::TagNotExist {
+            tag_name: tag_name.to_string(),
+        },
         Error::RestApi {
             source: RestError::NoSuchResource { .. },
         } => Error::TableNotExist {
@@ -928,7 +1017,9 @@ fn map_rest_error_for_tag(
             message,
             source: None,
         },
-        other => map_unsupported_endpoint(other, "tag"),
+        // Preserve HTTP status and resource metadata for catalog clients. A
+        // remote 501 is not a locally unsupported operation eligible for 
fallback.
+        other => other,
     }
 }
 
@@ -1144,16 +1235,58 @@ mod tests {
         };
 
         assert!(matches!(
-            map_rest_error_for_tag(error("tag"), &identifier, "release", None),
+            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)),
+            map_rest_error_for_tag(error("SNAPSHOT"), &identifier, "release", 
Some(7)),
             Error::SnapshotNotExist { snapshot_id: 7 }
         ));
+        // Java compares resource types exactly. Other 404 resources describe
+        // a missing table, including noncanonical lowercase names.
+        for resource_type in ["TABLE", "OTHER", "tag", "snapshot"] {
+            assert!(matches!(
+                map_rest_error_for_tag(error(resource_type), &identifier, 
"release", None),
+                Error::TableNotExist { full_name } if full_name == "db.table"
+            ));
+        }
+    }
+
+    #[test]
+    fn test_tag_error_mapping_preserves_missing_latest_snapshot() {
+        let identifier = Identifier::new("db", "table");
+        for name in [None, Some("null"), Some("7")] {
+            let error = Error::RestApi {
+                source: RestError::NoSuchResource {
+                    resource_type: Some("SNAPSHOT".to_string()),
+                    resource_name: name.map(str::to_string),
+                    message: "missing latest snapshot".to_string(),
+                },
+            };
+            let mapped = map_rest_error_for_tag(error, &identifier, "release", 
None);
+            if name == Some("7") {
+                assert!(matches!(mapped, Error::SnapshotNotExist { 
snapshot_id: 7 }));
+            } else {
+                assert!(matches!(mapped, Error::RestApi {
+                    source: RestError::NoSuchResource { resource_type, 
resource_name, message }
+                } if resource_type.as_deref() == Some("SNAPSHOT")
+                    && resource_name.as_deref() == name
+                    && message == "missing latest snapshot"));
+            }
+        }
+    }
+
+    #[test]
+    fn test_tag_error_mapping_preserves_unimplemented_endpoint() {
+        let error = Error::RestApi {
+            source: RestError::NotImplemented {
+                message: "tag endpoint unavailable".to_string(),
+            },
+        };
         assert!(matches!(
-            map_rest_error_for_tag(error("table"), &identifier, "release", 
None),
-            Error::TableNotExist { .. }
+            map_rest_error_for_tag(error, &Identifier::new("db", "table"), 
"release", None),
+            Error::RestApi { source: RestError::NotImplemented { message } }
+                if message == "tag endpoint unavailable"
         ));
     }
 
diff --git a/crates/paimon/src/spec/snapshot.rs 
b/crates/paimon/src/spec/snapshot.rs
index 1af888b0..ffb67462 100644
--- a/crates/paimon/src/spec/snapshot.rs
+++ b/crates/paimon/src/spec/snapshot.rs
@@ -62,8 +62,16 @@ pub struct Snapshot {
     schema_id: i64,
     /// a manifest list recording all changes from the previous snapshots
     base_manifest_list: String,
+    /// byte size of the base manifest list, absent in older snapshots
+    #[builder(default = None)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    base_manifest_list_size: Option<i64>,
     /// a manifest list recording all new changes occurred in this snapshot
     delta_manifest_list: String,
+    /// byte size of the delta manifest list, absent in older snapshots
+    #[builder(default = None)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    delta_manifest_list_size: Option<i64>,
     /// a manifest list recording all changelog produced in this snapshot
     #[builder(default = None)]
     #[serde(skip_serializing_if = "Option::is_none")]
@@ -154,12 +162,20 @@ impl Snapshot {
         &self.base_manifest_list
     }
 
+    pub fn base_manifest_list_size(&self) -> Option<i64> {
+        self.base_manifest_list_size
+    }
+
     /// Get the delta manifest list of this snapshot.
     #[inline]
     pub fn delta_manifest_list(&self) -> &str {
         &self.delta_manifest_list
     }
 
+    pub fn delta_manifest_list_size(&self) -> Option<i64> {
+        self.delta_manifest_list_size
+    }
+
     /// Get the changelog manifest list of this snapshot.
     #[inline]
     pub fn changelog_manifest_list(&self) -> Option<&str> {
diff --git a/bindings/python/src/lib.rs b/crates/paimon/src/table/instant.rs
similarity index 51%
copy from bindings/python/src/lib.rs
copy to crates/paimon/src/table/instant.rs
index 0b4508c8..c6e5f0ac 100644
--- a/bindings/python/src/lib.rs
+++ b/crates/paimon/src/table/instant.rs
@@ -15,42 +15,34 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use pyo3::prelude::*;
+//! Table rollback targets, matching Java's snapshot and tag instants.
 
-mod blob;
-mod blob_uri_reader;
-mod commit_callback;
-mod context;
-mod error;
-mod evolution;
-mod full_text_search;
-mod global_index_result;
-mod hybrid_search;
-mod index_build;
-mod merge_into;
-mod oss_cpp;
-mod predicate;
-mod read;
-mod schema;
-mod shard_update;
-mod system_table;
-mod table;
-mod udf;
-mod update_assignment;
-mod variant;
-mod vector_search;
-mod vindex;
-mod write;
-// ---- #285: observability ----
-mod partition;
-mod snapshot;
-mod tag;
+use serde::{Deserialize, Serialize};
 
-#[pymodule]
-fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
-    error::register_module(py, m)?;
-    context::register_module(py, m)?;
-    variant::register_module(py, m)?;
-    evolution::register_module(m)?;
-    Ok(())
+/// A snapshot or tag to which a catalog rolls back a table.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum Instant {
+    #[serde(rename = "snapshot")]
+    Snapshot {
+        #[serde(rename = "snapshotId")]
+        snapshot_id: i64,
+    },
+    #[serde(rename = "tag")]
+    Tag {
+        #[serde(rename = "tagName")]
+        tag_name: String,
+    },
+}
+
+impl Instant {
+    pub fn snapshot(snapshot_id: i64) -> Self {
+        Self::Snapshot { snapshot_id }
+    }
+
+    pub fn tag(tag_name: impl Into<String>) -> Self {
+        Self::Tag {
+            tag_name: tag_name.into(),
+        }
+    }
 }
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 08a84743..33382b11 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -86,6 +86,7 @@ mod hybrid_search_builder;
 mod incremental_scan;
 pub(crate) mod index_file_path;
 mod inline_blob;
+mod instant;
 mod kv_file_reader;
 mod kv_file_writer;
 mod lumina_index_build_builder;
@@ -216,6 +217,7 @@ pub use hybrid_search_builder::{
 pub use incremental_scan::{
     IncrementalPlan, IncrementalScan, IncrementalScanMode, IncrementalSplit,
 };
+pub use instant::Instant;
 pub use object_table::{ObjectEntry, ObjectTable};
 pub use partition_row_count::PartitionRowCount;
 pub use partition_stat::PartitionStat;
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index eeb2ad47..e70828c8 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -1055,11 +1055,11 @@ impl RESTServer {
 
         let key = format!("{db}.{table}.{}", request.tag_name);
         if state.tags.contains_key(&key) {
-            return resource_error(StatusCode::CONFLICT, "tag", 
&request.tag_name);
+            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());
+            return resource_error(StatusCode::NOT_FOUND, "SNAPSHOT", 
&snapshot_id.to_string());
         }
         state.tags.insert(
             key,
@@ -1083,7 +1083,7 @@ impl RESTServer {
         }
         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),
+            None => resource_error(StatusCode::NOT_FOUND, "TAG", &tag),
         }
     }
 
@@ -1098,7 +1098,7 @@ impl RESTServer {
         if state.tags.remove(&format!("{db}.{table}.{tag}")).is_some() {
             (StatusCode::OK, Json(json!(""))).into_response()
         } else {
-            resource_error(StatusCode::NOT_FOUND, "tag", &tag)
+            resource_error(StatusCode::NOT_FOUND, "TAG", &tag)
         }
     }
 
diff --git a/crates/paimon/tests/rest_branch_test.rs 
b/crates/paimon/tests/rest_branch_test.rs
index 1b5a4f00..7dbfde17 100644
--- a/crates/paimon/tests/rest_branch_test.rs
+++ b/crates/paimon/tests/rest_branch_test.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Java REST branch protocol, exercised through the public Catalog contract.
+//! Java REST branch and version protocols, exercised through the public 
Catalog contract.
 
 use std::collections::VecDeque;
 use std::sync::{Arc, Mutex};
@@ -31,6 +31,360 @@ use paimon::common::Options;
 use paimon::Error;
 use serde_json::{json, Value};
 
+fn version_snapshot(id: i64) -> Value {
+    json!({
+        "version": 3, "id": id, "schemaId": 0,
+        "baseManifestList": "base", "deltaManifestList": "delta",
+        "baseManifestListSize": 321, "deltaManifestListSize": 0,
+        "commitUser": "writer", "commitIdentifier": 1, "commitKind": "APPEND",
+        "timeMillis": 123, "uuid": "snapshot-uuid", "totalRecordCount": 8,
+    })
+}
+
+#[tokio::test]
+async fn rollback_instants_and_optional_guards_match_java() {
+    use paimon::table::Instant;
+    for instant in [Instant::snapshot(7), Instant::tag("release /一")] {
+        for from_snapshot in [None, Some(0), Some(9)] {
+            for table_uuid in [None, Some("table-incarnation")] {
+                let ctx = TestCatalog::new_raw(vec![(StatusCode::OK, 
"OK".to_string())]).await;
+                let id = Identifier::new("db /一", "table$branch_dev /一");
+                ctx.catalog
+                    .rollback_to(&id, &instant, from_snapshot, table_uuid)
+                    .await
+                    .unwrap();
+                let requests = ctx.requests();
+                assert_eq!(requests.len(), 1);
+                assert_eq!(requests[0].method, Method::POST);
+                assert!(requests[0]
+                    .uri
+                    
.ends_with("/tables/table%24branch_dev+%2F%E4%B8%80/rollback"));
+                let mut expected = json!({"instant": instant});
+                if let Some(from_snapshot) = from_snapshot {
+                    expected["fromSnapshot"] = json!(from_snapshot);
+                }
+                if let Some(uuid) = table_uuid {
+                    expected["tableId"] = json!(uuid);
+                }
+                assert_eq!(requests[0].body, Some(expected));
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn rejected_rollback_preserves_guards_without_reload_or_retry() {
+    use paimon::table::Instant;
+    for status in [400, 403, 409, 429, 500, 501, 503] {
+        let ctx = TestCatalog::new(vec![(
+            StatusCode::from_u16(status).unwrap(),
+            json!({"message": "rejected"}),
+        )])
+        .await;
+        let id = Identifier::new("db", "t");
+        let error = ctx
+            .catalog
+            .rollback_to(&id, &Instant::snapshot(1), Some(2), 
Some("old-table"))
+            .await
+            .unwrap_err();
+        assert!(matches!(error, Error::RestApi { .. }), "{error:?}");
+        let requests = ctx.requests();
+        assert_eq!(requests.len(), 1);
+        assert_eq!(
+            requests[0].body,
+            Some(json!({
+                "instant": {"type":"snapshot", "snapshotId":1},
+                "fromSnapshot":2, "tableId":"old-table",
+            }))
+        );
+    }
+}
+
+#[tokio::test]
+async fn rollback_missing_targets_remain_distinct_from_missing_tables() {
+    use paimon::table::Instant;
+    for resource in ["SNAPSHOT", "TAG", "TABLE", "OTHER"] {
+        let name = if resource == "SNAPSHOT" {
+            "7"
+        } else {
+            "release"
+        };
+        let ctx = TestCatalog::new(vec![(
+            StatusCode::NOT_FOUND,
+            json!({
+                "resourceType":resource, "resourceName":name, 
"message":"missing",
+            }),
+        )])
+        .await;
+        let error = ctx
+            .catalog
+            .rollback_to(
+                &Identifier::new("db", "t"),
+                &Instant::tag("release"),
+                None,
+                None,
+            )
+            .await
+            .unwrap_err();
+        match resource {
+            "SNAPSHOT" => assert!(matches!(error, Error::SnapshotNotExist { 
snapshot_id: 7 })),
+            "TAG" => {
+                assert!(matches!(error, Error::TagNotExist { tag_name } if 
tag_name == "release"))
+            }
+            _ => {
+                assert!(matches!(error, Error::TableNotExist { full_name } if 
full_name == "db.t"))
+            }
+        }
+        assert_eq!(ctx.requests().len(), 1);
+    }
+}
+
+#[tokio::test]
+async fn missing_latest_snapshot_is_not_an_invalid_tag_request() {
+    for name in [Some("null"), None] {
+        let ctx = TestCatalog::new(vec![(
+            StatusCode::NOT_FOUND,
+            json!({"resourceType":"SNAPSHOT", "resourceName":name, 
"message":"empty table"}),
+        )])
+        .await;
+        let error = ctx
+            .catalog
+            .create_tag(&Identifier::new("db", "t"), "release", None, false)
+            .await
+            .unwrap_err();
+        assert!(matches!(error, Error::RestApi {
+            source: RestError::NoSuchResource { resource_type, resource_name, 
.. }
+        } if resource_type.as_deref() == Some("SNAPSHOT") && 
resource_name.as_deref() == name));
+        assert_eq!(ctx.requests().len(), 1);
+    }
+}
+
+#[tokio::test]
+async fn tag_pages_preserve_query_encoding_order_duplicates_and_tokens() {
+    let token = "next +/%一";
+    let prefix = "release +/%一";
+    let ctx = TestCatalog::new(vec![(
+        StatusCode::OK,
+        json!({
+            "tags":["z","a","z","一"], "nextPageToken":token,
+        }),
+    )])
+    .await;
+    let page = ctx
+        .catalog
+        .list_tags_paged(
+            &Identifier::new("db", "t$branch_dev /一"),
+            Some(3),
+            Some(token),
+            Some(prefix),
+        )
+        .await
+        .unwrap();
+    assert_eq!(page.elements, vec!["z", "a", "z", "一"]);
+    assert_eq!(page.next_page_token.as_deref(), Some(token));
+    let requests = ctx.requests();
+    assert_eq!(requests.len(), 1);
+    let url = reqwest::Url::parse(&format!("http://server{}";, 
requests[0].uri)).unwrap();
+    let query: std::collections::HashMap<_, _> = 
url.query_pairs().into_owned().collect();
+    assert_eq!(
+        query,
+        std::collections::HashMap::from([
+            ("maxResults".to_string(), "3".to_string()),
+            ("pageToken".to_string(), token.to_string()),
+            ("tagNamePrefix".to_string(), prefix.to_string()),
+        ])
+    );
+    assert!(url.path().ends_with("t%24branch_dev+%2F%E4%B8%80/tags"));
+}
+
+#[tokio::test]
+async fn tag_page_nulls_and_empty_parameters_match_java() {
+    for (body, expected_token) in [
+        (json!({"tags":null,"nextPageToken":"discard"}), None),
+        (json!({"nextPageToken":"discard"}), None),
+        (json!({"tags":[],"nextPageToken":"keep"}), Some("keep")),
+    ] {
+        for max in [None, Some(0), Some(-1)] {
+            let ctx = TestCatalog::new(vec![(StatusCode::OK, 
body.clone())]).await;
+            let page = ctx
+                .catalog
+                .list_tags_paged(&Identifier::new("db", "t"), max, Some(""), 
Some(""))
+                .await
+                .unwrap();
+            assert!(page.elements.is_empty());
+            assert_eq!(page.next_page_token.as_deref(), expected_token);
+            let requests = ctx.requests();
+            assert!(
+                requests[0].uri.ends_with("/tags?pageToken="),
+                "{requests:?}"
+            );
+        }
+    }
+}
+
+#[tokio::test]
+async fn tag_page_errors_are_table_errors_and_keep_permissions() {
+    for status in [StatusCode::NOT_FOUND, StatusCode::FORBIDDEN] {
+        let ctx = TestCatalog::new(vec![(
+            status,
+            json!({"resourceType":"TABLE","message":"denied"}),
+        )])
+        .await;
+        let error = ctx
+            .catalog
+            .list_tags_paged(&Identifier::new("db", "t"), None, None, None)
+            .await
+            .unwrap_err();
+        if status == StatusCode::NOT_FOUND {
+            assert!(matches!(error, Error::TableNotExist { .. }));
+        } else {
+            assert!(matches!(
+                error,
+                Error::RestApi {
+                    source: RestError::Forbidden { .. }
+                }
+            ));
+        }
+        assert_eq!(ctx.requests().len(), 1);
+    }
+}
+
+#[tokio::test]
+async fn catalog_snapshot_keeps_all_statistics_and_snapshot_metadata() {
+    let ctx = TestCatalog::new(vec![(
+        StatusCode::OK,
+        json!({"snapshot":{
+            "snapshot": version_snapshot(7), 
"recordCount":0,"fileSizeInBytes":123,
+            "fileCount":2,"lastFileCreationTime":456,
+        }}),
+    )])
+    .await;
+    let snapshot = ctx
+        .catalog
+        .load_snapshot(&Identifier::new("db", "t$branch_dev"))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(snapshot.snapshot.id(), 7);
+    assert_eq!(snapshot.snapshot.uuid(), Some("snapshot-uuid"));
+    assert_eq!(snapshot.snapshot.base_manifest_list_size(), Some(321));
+    assert_eq!(snapshot.snapshot.delta_manifest_list_size(), Some(0));
+    assert_eq!(snapshot.record_count, Some(0));
+    assert_eq!(snapshot.file_size_in_bytes, Some(123));
+    assert_eq!(snapshot.file_count, Some(2));
+    assert_eq!(snapshot.last_file_creation_time, Some(456));
+    assert_eq!(ctx.requests().len(), 1);
+}
+
+#[tokio::test]
+async fn absent_catalog_snapshot_is_empty_but_missing_table_is_not() {
+    for body in [Value::Null, json!({"snapshot":null}), json!({})] {
+        let ctx = TestCatalog::new(vec![(StatusCode::OK, body)]).await;
+        assert!(ctx
+            .catalog
+            .load_snapshot(&Identifier::new("db", "t"))
+            .await
+            .unwrap()
+            .is_none());
+        assert_eq!(ctx.requests().len(), 1);
+    }
+    let ctx = TestCatalog::new(vec![(
+        StatusCode::NOT_FOUND,
+        json!({"resourceType":"SNAPSHOT","message":"empty"}),
+    )])
+    .await;
+    assert!(ctx
+        .catalog
+        .load_snapshot(&Identifier::new("db", "t"))
+        .await
+        .unwrap()
+        .is_none());
+    assert_eq!(ctx.requests().len(), 1);
+    let ctx = TestCatalog::new(vec![(
+        StatusCode::NOT_FOUND,
+        json!({"resourceType":"TABLE","message":"missing"}),
+    )])
+    .await;
+    assert!(matches!(
+        ctx.catalog.load_snapshot(&Identifier::new("db", "t")).await,
+        Err(Error::TableNotExist { .. })
+    ));
+    assert_eq!(ctx.requests().len(), 1);
+}
+
+#[tokio::test]
+async fn 
tag_retention_and_metadata_are_opaque_and_mutations_ignore_success_bodies() {
+    let name = "release /一";
+    let ctx = TestCatalog::new_raw(vec![
+        (StatusCode::OK, "created".to_string()),
+        (
+            StatusCode::OK,
+            json!({"tagName":name,"snapshot":version_snapshot(7),
+            "tagCreateTime":123,"tagTimeRetained":"2 d"})
+            .to_string(),
+        ),
+        (StatusCode::NO_CONTENT, String::new()),
+    ])
+    .await;
+    let id = Identifier::new("db", "t");
+    ctx.catalog
+        .create_tag_with_retention(&id, name, Some(7), Some("2 d"), false)
+        .await
+        .unwrap();
+    let response = ctx.catalog.get_tag(&id, name).await.unwrap();
+    assert_eq!(response.snapshot.id(), 7);
+    let serialized = serde_json::to_value(&response.snapshot).unwrap();
+    assert_eq!(serialized["baseManifestListSize"], 321);
+    assert_eq!(serialized["deltaManifestListSize"], 0);
+    assert_eq!(response.tag_create_time, Some(123));
+    assert_eq!(response.tag_time_retained.as_deref(), Some("2 d"));
+    ctx.catalog.delete_tag(&id, name, false).await.unwrap();
+    let requests = ctx.requests();
+    assert_eq!(
+        requests[0].body,
+        Some(json!({"tagName":name,"snapshotId":7,"timeRetained":"2 d"}))
+    );
+    assert!(requests[1].uri.ends_with("/tags/release+%2F%E4%B8%80"));
+    assert_eq!(requests[2].method, Method::DELETE);
+}
+
+#[tokio::test]
+async fn version_paths_reject_parent_resources_before_requests() {
+    use paimon::table::Instant;
+    let ctx = TestCatalog::new(vec![]).await;
+    for id in [Identifier::new("..", "t"), Identifier::new("db", ".")] {
+        assert!(ctx
+            .catalog
+            .rollback_to(&id, &Instant::snapshot(1), None, None)
+            .await
+            .is_err());
+        assert!(ctx.catalog.load_snapshot(&id).await.is_err());
+        assert!(ctx
+            .catalog
+            .list_tags_paged(&id, None, None, None)
+            .await
+            .is_err());
+        assert!(ctx
+            .catalog
+            .create_tag(&id, "release", None, false)
+            .await
+            .is_err());
+    }
+    for name in ["", " ", ".", ".."] {
+        assert!(ctx
+            .catalog
+            .get_tag(&Identifier::new("db", "t"), name)
+            .await
+            .is_err());
+        assert!(ctx
+            .catalog
+            .delete_tag(&Identifier::new("db", "t"), name, false)
+            .await
+            .is_err());
+    }
+    assert!(ctx.requests().is_empty());
+}
+
 #[derive(Debug, PartialEq)]
 struct Request {
     method: Method,

Reply via email to