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 7c7120b0 feat(go): expose snapshot and tag APIs (#828)
7c7120b0 is described below
commit 7c7120b0afffbf1ad0be28e14ce9bc5e13b19560
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Sep 14 21:46:37 2026 +0800
feat(go): expose snapshot and tag APIs (#828)
---
bindings/c/src/catalog.rs | 133 ++++++++++++++++-
bindings/c/src/error.rs | 3 +
bindings/c/src/result.rs | 12 ++
bindings/c/src/table.rs | 42 +++++-
bindings/c/src/tests.rs | 142 +++++++++++++++++-
bindings/c/src/types.rs | 7 +
bindings/go/catalog.go | 7 +-
bindings/go/catalog_tag.go | 263 ++++++++++++++++++++++++++++++++++
bindings/go/tests/catalog_tag_test.go | 110 ++++++++++++++
bindings/go/types.go | 41 ++++++
docs/src/go-binding.md | 38 +++++
11 files changed, 785 insertions(+), 13 deletions(-)
diff --git a/bindings/c/src/catalog.rs b/bindings/c/src/catalog.rs
index 9c5c683b..49c2fb48 100644
--- a/bindings/c/src/catalog.rs
+++ b/bindings/c/src/catalog.rs
@@ -15,16 +15,16 @@
// specific language governing permissions and limitations
// under the License.
-use std::ffi::c_void;
+use std::ffi::{c_char, c_void};
use std::sync::Arc;
use paimon::catalog::Identifier;
use paimon::{Catalog, CatalogFactory, Options};
-use crate::error::{check_non_null, paimon_error, validate_cstr};
-use crate::result::{paimon_result_catalog_new, paimon_result_get_table};
+use crate::error::{check_non_null, paimon_error, validate_cstr,
PaimonErrorCode};
+use crate::result::{paimon_result_catalog_new, paimon_result_get_table,
paimon_result_get_tag};
use crate::runtime;
-use crate::types::{paimon_catalog, paimon_option, paimon_table};
+use crate::types::{paimon_bytes, paimon_catalog, paimon_identifier,
paimon_option, paimon_table};
/// Create a catalog using CatalogFactory with the given options.
///
@@ -136,3 +136,128 @@ pub unsafe extern "C" fn paimon_catalog_get_table(
},
}
}
+
+/// Create a tag for a snapshot, or the latest snapshot when `snapshot_id` is
null.
+///
+/// # Safety
+/// `catalog` and `identifier` must be valid pointers from previous paimon C
calls.
+/// `tag_name` must be a valid null-terminated C string.
+/// If non-null, `snapshot_id` must point to a valid `i64`.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_catalog_create_tag(
+ catalog: *const paimon_catalog,
+ identifier: *const paimon_identifier,
+ tag_name: *const c_char,
+ snapshot_id: *const i64,
+ ignore_if_exists: bool,
+) -> *mut paimon_error {
+ if let Err(error) = check_non_null(catalog, "catalog") {
+ return error;
+ }
+ if let Err(error) = check_non_null(identifier, "identifier") {
+ return error;
+ }
+ let tag_name = match validate_cstr(tag_name, "tag_name") {
+ Ok(tag_name) => tag_name,
+ Err(error) => return error,
+ };
+
+ let catalog = &*((*catalog).inner as *const Arc<dyn Catalog>);
+ let identifier = &*((*identifier).inner as *const Identifier);
+ let snapshot_id = snapshot_id.as_ref().copied();
+ match runtime().block_on(catalog.create_tag(
+ identifier,
+ &tag_name,
+ snapshot_id,
+ ignore_if_exists,
+ )) {
+ Ok(()) => std::ptr::null_mut(),
+ Err(error) => paimon_error::from_paimon(error),
+ }
+}
+
+/// Get a tag and its snapshot metadata as JSON.
+///
+/// # Safety
+/// `catalog` and `identifier` must be valid pointers from previous paimon C
calls.
+/// `tag_name` must be a valid null-terminated C string.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_catalog_get_tag(
+ catalog: *const paimon_catalog,
+ identifier: *const paimon_identifier,
+ tag_name: *const c_char,
+) -> paimon_result_get_tag {
+ if let Err(error) = check_non_null(catalog, "catalog") {
+ return paimon_result_get_tag {
+ tag: paimon_bytes::empty(),
+ error,
+ };
+ }
+ if let Err(error) = check_non_null(identifier, "identifier") {
+ return paimon_result_get_tag {
+ tag: paimon_bytes::empty(),
+ error,
+ };
+ }
+ let tag_name = match validate_cstr(tag_name, "tag_name") {
+ Ok(tag_name) => tag_name,
+ Err(error) => {
+ return paimon_result_get_tag {
+ tag: paimon_bytes::empty(),
+ error,
+ }
+ }
+ };
+
+ let catalog = &*((*catalog).inner as *const Arc<dyn Catalog>);
+ let identifier = &*((*identifier).inner as *const Identifier);
+ match runtime().block_on(catalog.get_tag(identifier, &tag_name)) {
+ Ok(tag) => match serde_json::to_vec(&tag) {
+ Ok(tag) => paimon_result_get_tag {
+ tag: paimon_bytes::new(tag),
+ error: std::ptr::null_mut(),
+ },
+ Err(error) => paimon_result_get_tag {
+ tag: paimon_bytes::empty(),
+ error: paimon_error::new(
+ PaimonErrorCode::Unexpected,
+ format!("failed to serialize tag metadata: {error}"),
+ ),
+ },
+ },
+ Err(error) => paimon_result_get_tag {
+ tag: paimon_bytes::empty(),
+ error: paimon_error::from_paimon(error),
+ },
+ }
+}
+
+/// Delete a tag.
+///
+/// # Safety
+/// `catalog` and `identifier` must be valid pointers from previous paimon C
calls.
+/// `tag_name` must be a valid null-terminated C string.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_catalog_delete_tag(
+ catalog: *const paimon_catalog,
+ identifier: *const paimon_identifier,
+ tag_name: *const c_char,
+) -> *mut paimon_error {
+ if let Err(error) = check_non_null(catalog, "catalog") {
+ return error;
+ }
+ if let Err(error) = check_non_null(identifier, "identifier") {
+ return error;
+ }
+ let tag_name = match validate_cstr(tag_name, "tag_name") {
+ Ok(tag_name) => tag_name,
+ Err(error) => return error,
+ };
+
+ let catalog = &*((*catalog).inner as *const Arc<dyn Catalog>);
+ let identifier = &*((*identifier).inner as *const Identifier);
+ match runtime().block_on(catalog.delete_tag(identifier, &tag_name, false))
{
+ Ok(()) => std::ptr::null_mut(),
+ Err(error) => paimon_error::from_paimon(error),
+ }
+}
diff --git a/bindings/c/src/error.rs b/bindings/c/src/error.rs
index 7b0a88fc..db82773d 100644
--- a/bindings/c/src/error.rs
+++ b/bindings/c/src/error.rs
@@ -52,9 +52,12 @@ impl paimon_error {
}
paimon::Error::TableNotExist { .. }
| paimon::Error::DatabaseNotExist { .. }
+ | paimon::Error::SnapshotNotExist { .. }
+ | paimon::Error::TagNotExist { .. }
| paimon::Error::ColumnNotExist { .. } =>
PaimonErrorCode::NotFound,
paimon::Error::TableAlreadyExist { .. }
| paimon::Error::DatabaseAlreadyExist { .. }
+ | paimon::Error::TagAlreadyExist { .. }
| paimon::Error::ColumnAlreadyExist { .. } =>
PaimonErrorCode::AlreadyExists,
paimon::Error::ConfigInvalid { .. }
| paimon::Error::DataTypeInvalid { .. }
diff --git a/bindings/c/src/result.rs b/bindings/c/src/result.rs
index 3f3b7f98..60da7c09 100644
--- a/bindings/c/src/result.rs
+++ b/bindings/c/src/result.rs
@@ -72,6 +72,18 @@ pub struct paimon_result_get_table {
pub error: *mut paimon_error,
}
+#[repr(C)]
+pub struct paimon_result_get_tag {
+ pub tag: paimon_bytes,
+ pub error: *mut paimon_error,
+}
+
+#[repr(C)]
+pub struct paimon_result_latest_snapshot {
+ pub snapshot: paimon_bytes,
+ pub error: *mut paimon_error,
+}
+
#[repr(C)]
pub struct paimon_result_new_read {
pub read: *mut paimon_table_read,
diff --git a/bindings/c/src/table.rs b/bindings/c/src/table.rs
index 0b18de70..058c95d8 100644
--- a/bindings/c/src/table.rs
+++ b/bindings/c/src/table.rs
@@ -30,9 +30,9 @@ use paimon::Plan;
use crate::error::{check_non_null, paimon_error, validate_cstr,
PaimonErrorCode};
use crate::file_io::file_io_ref;
use crate::result::{
- paimon_result_get_table, paimon_result_new_read, paimon_result_next_batch,
paimon_result_plan,
- paimon_result_predicate, paimon_result_read_builder,
paimon_result_record_batch_reader,
- paimon_result_table_scan,
+ paimon_result_get_table, paimon_result_latest_snapshot,
paimon_result_new_read,
+ paimon_result_next_batch, paimon_result_plan, paimon_result_predicate,
+ paimon_result_read_builder, paimon_result_record_batch_reader,
paimon_result_table_scan,
};
use crate::runtime;
use crate::types::*;
@@ -336,6 +336,42 @@ pub unsafe extern "C" fn paimon_table_free(table: *mut
paimon_table) {
free_table_wrapper(table, |t| t.inner);
}
+/// Return the latest snapshot as JSON, or JSON null when the table is empty.
+///
+/// # Safety
+/// `table` must be a valid pointer from a previous paimon C call.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_table_latest_snapshot(
+ table: *const paimon_table,
+) -> paimon_result_latest_snapshot {
+ if let Err(error) = check_non_null(table, "table") {
+ return paimon_result_latest_snapshot {
+ snapshot: paimon_bytes::empty(),
+ error,
+ };
+ }
+ let table = &*((*table).inner as *const Table);
+ match runtime().block_on(table.snapshot_manager().get_latest_snapshot()) {
+ Ok(snapshot) => match serde_json::to_vec(&snapshot) {
+ Ok(snapshot) => paimon_result_latest_snapshot {
+ snapshot: paimon_bytes::new(snapshot),
+ error: std::ptr::null_mut(),
+ },
+ Err(error) => paimon_result_latest_snapshot {
+ snapshot: paimon_bytes::empty(),
+ error: paimon_error::new(
+ PaimonErrorCode::Unexpected,
+ format!("failed to serialize snapshot metadata: {error}"),
+ ),
+ },
+ },
+ Err(error) => paimon_result_latest_snapshot {
+ snapshot: paimon_bytes::empty(),
+ error: paimon_error::from_paimon(error),
+ },
+ }
+}
+
/// Time-travel selector option names, in the core's resolution priority order.
const TIME_TRAVEL_SELECTORS: [&str; 5] = [
"scan.timestamp-millis",
diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index dc66432f..570c85a8 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -44,11 +44,14 @@ use paimon::spec::{
BlobDescriptor, CommitKind, DataType, IntType, Schema, TableSchema,
VarCharType,
};
use paimon::table::{SnapshotManager, Table};
+use paimon::{Catalog, FileSystemCatalog, Options};
use crate::blob_reader::*;
use crate::bucket_vector_search_split::*;
+use crate::catalog::*;
use crate::error::*;
use crate::file_io::*;
+use crate::identifier::*;
use crate::table::*;
use crate::types::*;
use crate::vector_read::*;
@@ -64,13 +67,16 @@ fn memory_file_io() -> paimon::io::FileIO {
FileIOBuilder::new("memory").build().unwrap()
}
-fn simple_table_schema() -> TableSchema {
- let schema = Schema::builder()
+fn simple_schema() -> Schema {
+ Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("name", DataType::VarChar(VarCharType::string_type()))
.build()
- .unwrap();
- TableSchema::new(0, &schema)
+ .unwrap()
+}
+
+fn simple_table_schema() -> TableSchema {
+ TableSchema::new(0, &simple_schema())
}
fn not_null_table_schema() -> TableSchema {
@@ -100,6 +106,11 @@ unsafe fn wrap_table(table: Table) -> *mut paimon_table {
Box::into_raw(Box::new(paimon_table { inner }))
}
+unsafe fn wrap_catalog(catalog: Arc<dyn Catalog>) -> *mut paimon_catalog {
+ let inner = Box::into_raw(Box::new(catalog)) as *mut c_void;
+ Box::into_raw(Box::new(paimon_catalog { inner }))
+}
+
unsafe fn unwrap_table(table: *mut paimon_table) {
let wrapper = Box::from_raw(table);
if !wrapper.inner.is_null() {
@@ -953,6 +964,129 @@ fn
test_table_from_schema_json_rejects_invalid_identifier() {
}
}
+// =========================================================================
+// Catalog tag tests
+// =========================================================================
+
+#[test]
+fn test_catalog_tag_lifecycle() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let mut options = Options::new();
+ options.set("warehouse", temp_dir.path().to_string_lossy());
+ let catalog = FileSystemCatalog::new(options).unwrap();
+ let identifier = Identifier::new("default", "test");
+ let table = crate::runtime().block_on(async {
+ catalog
+ .create_database("default", false, HashMap::new())
+ .await
+ .unwrap();
+ catalog
+ .create_table(&identifier, simple_schema(), false)
+ .await
+ .unwrap();
+ catalog.get_table(&identifier).await.unwrap()
+ });
+ unsafe {
+ let empty_table = wrap_table(table.clone());
+ let latest = paimon_table_latest_snapshot(empty_table);
+ assert!(latest.error.is_null());
+ let json = std::slice::from_raw_parts(latest.snapshot.data,
latest.snapshot.len);
+ assert!(
+ serde_json::from_slice::<Option<paimon::spec::Snapshot>>(json)
+ .unwrap()
+ .is_none()
+ );
+ paimon_bytes_free(latest.snapshot);
+ paimon_table_free(empty_table);
+ }
+ write_data_rust(&table, &[make_batch(vec![1], vec!["a"])]);
+
+ unsafe {
+ let table = wrap_table(table);
+ let latest_result = paimon_table_latest_snapshot(table);
+ assert!(latest_result.error.is_null());
+ let latest_json =
+ std::slice::from_raw_parts(latest_result.snapshot.data,
latest_result.snapshot.len);
+ let latest: Option<paimon::spec::Snapshot> =
serde_json::from_slice(latest_json).unwrap();
+ assert_eq!(latest.unwrap().id(), 1);
+ paimon_bytes_free(latest_result.snapshot);
+ paimon_table_free(table);
+
+ let catalog = wrap_catalog(Arc::new(catalog));
+ let database = CString::new("default").unwrap();
+ let object = CString::new("test").unwrap();
+ let identifier = paimon_identifier_new(database.as_ptr(),
object.as_ptr());
+ assert!(identifier.error.is_null());
+ let tag_name = CString::new("release-1").unwrap();
+
+ let error = paimon_catalog_create_tag(
+ catalog,
+ identifier.identifier,
+ tag_name.as_ptr(),
+ ptr::null(),
+ false,
+ );
+ assert!(error.is_null());
+
+ let tag_result = paimon_catalog_get_tag(catalog,
identifier.identifier, tag_name.as_ptr());
+ assert!(tag_result.error.is_null());
+ let tag_json = std::slice::from_raw_parts(tag_result.tag.data,
tag_result.tag.len);
+ let tag: paimon::api::GetTagResponse =
serde_json::from_slice(tag_json).unwrap();
+ assert_eq!(tag.tag_name, "release-1");
+ assert_eq!(tag.snapshot.id(), 1);
+
+ let explicit_name = CString::new("release-explicit").unwrap();
+ let snapshot_id = 1;
+ let error = paimon_catalog_create_tag(
+ catalog,
+ identifier.identifier,
+ explicit_name.as_ptr(),
+ &snapshot_id,
+ false,
+ );
+ assert!(error.is_null());
+ let missing_snapshot_id = 2;
+ let missing_snapshot_name = CString::new("missing-snapshot").unwrap();
+ let missing = paimon_catalog_create_tag(
+ catalog,
+ identifier.identifier,
+ missing_snapshot_name.as_ptr(),
+ &missing_snapshot_id,
+ false,
+ );
+ assert_eq!((*missing).code, PaimonErrorCode::NotFound as i32);
+ paimon_error_free(missing);
+
+ let duplicate = paimon_catalog_create_tag(
+ catalog,
+ identifier.identifier,
+ tag_name.as_ptr(),
+ ptr::null(),
+ false,
+ );
+ assert_eq!((*duplicate).code, PaimonErrorCode::AlreadyExists as i32);
+ paimon_error_free(duplicate);
+
+ let error = paimon_catalog_delete_tag(catalog, identifier.identifier,
tag_name.as_ptr());
+ assert!(error.is_null());
+ let missing = paimon_catalog_get_tag(catalog, identifier.identifier,
tag_name.as_ptr());
+ assert_eq!((*missing.error).code, PaimonErrorCode::NotFound as i32);
+ paimon_error_free(missing.error);
+
+ let missing = paimon_catalog_delete_tag(catalog,
identifier.identifier, tag_name.as_ptr());
+ assert_eq!((*missing).code, PaimonErrorCode::NotFound as i32);
+ paimon_error_free(missing);
+
+ let error =
+ paimon_catalog_delete_tag(catalog, identifier.identifier,
explicit_name.as_ptr());
+ assert!(error.is_null());
+
+ paimon_bytes_free(tag_result.tag);
+ paimon_identifier_free(identifier.identifier);
+ paimon_catalog_free(catalog);
+ }
+}
+
// =========================================================================
// Read path tests
// =========================================================================
diff --git a/bindings/c/src/types.rs b/bindings/c/src/types.rs
index ede86664..212a60ce 100644
--- a/bindings/c/src/types.rs
+++ b/bindings/c/src/types.rs
@@ -41,6 +41,13 @@ pub struct paimon_bytes {
}
impl paimon_bytes {
+ pub fn empty() -> Self {
+ Self {
+ data: std::ptr::null_mut(),
+ len: 0,
+ }
+ }
+
pub fn new(v: Vec<u8>) -> Self {
let boxed = v.into_boxed_slice();
let len = boxed.len();
diff --git a/bindings/go/catalog.go b/bindings/go/catalog.go
index 9ebba9db..b813a7ca 100644
--- a/bindings/go/catalog.go
+++ b/bindings/go/catalog.go
@@ -72,8 +72,7 @@ func (c *Catalog) GetTable(id Identifier) (*Table, error) {
if c.inner == nil {
return nil, ErrClosed
}
- createIdFn := ffiIdentifierNew.symbol(c.ctx)
- cID, err := createIdFn(id.database, id.object)
+ cID, err := c.newIdentifier(id)
if err != nil {
return nil, err
}
@@ -88,6 +87,10 @@ func (c *Catalog) GetTable(id Identifier) (*Table, error) {
return &Table{ctx: c.ctx, lib: c.lib, inner: inner}, nil
}
+func (c *Catalog) newIdentifier(id Identifier) (*paimonIdentifier, error) {
+ return ffiIdentifierNew.symbol(c.ctx)(id.database, id.object)
+}
+
var ffiCatalogCreate = newFFI(ffiOpts{
sym: "paimon_catalog_create",
rType: &typeResultCatalogNew,
diff --git a/bindings/go/catalog_tag.go b/bindings/go/catalog_tag.go
new file mode 100644
index 00000000..594c4901
--- /dev/null
+++ b/bindings/go/catalog_tag.go
@@ -0,0 +1,263 @@
+/*
+ * 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.
+ */
+
+package paimon
+
+import (
+ "context"
+ "encoding/json"
+ "runtime"
+ "unsafe"
+
+ "github.com/jupiterrider/ffi"
+)
+
+// CommitKind identifies the change represented by a snapshot.
+type CommitKind string
+
+const (
+ CommitKindAppend CommitKind = "APPEND"
+ CommitKindCompact CommitKind = "COMPACT"
+ CommitKindOverwrite CommitKind = "OVERWRITE"
+ CommitKindAnalyze CommitKind = "ANALYZE"
+)
+
+// Snapshot describes a Paimon table snapshot.
+type Snapshot struct {
+ ID int64 `json:"id"`
+ CommitKind CommitKind `json:"commitKind"`
+ TimeMillis int64 `json:"timeMillis"`
+ TotalRecordCount *int64 `json:"totalRecordCount,omitempty"`
+ DeltaRecordCount *int64 `json:"deltaRecordCount,omitempty"`
+}
+
+// GetTagResponse contains a tag and its snapshot metadata.
+type GetTagResponse struct {
+ TagName string `json:"tagName"`
+ Snapshot Snapshot `json:"snapshot"`
+ TagCreateTime *int64 `json:"tagCreateTime,omitempty"`
+ TagTimeRetained *string `json:"tagTimeRetained,omitempty"`
+}
+
+// LatestSnapshot returns the latest snapshot, or nil when the table is empty.
+func (t *Table) LatestSnapshot() (*Snapshot, error) {
+ if t.inner == nil {
+ return nil, ErrClosed
+ }
+ return ffiTableLatestSnapshot.symbol(t.ctx)(t.inner)
+}
+
+// CreateTag creates a tag for snapshotID, or the latest snapshot when
snapshotID is nil.
+func (c *Catalog) CreateTag(
+ id Identifier,
+ tagName string,
+ snapshotID *int64,
+ ignoreIfExists bool,
+) error {
+ if c.inner == nil {
+ return ErrClosed
+ }
+ cID, err := c.newIdentifier(id)
+ if err != nil {
+ return err
+ }
+ defer ffiIdentifierFree.symbol(c.ctx)(cID)
+ return ffiCatalogCreateTag.symbol(c.ctx)(
+ c.inner,
+ cID,
+ tagName,
+ snapshotID,
+ ignoreIfExists,
+ )
+}
+
+// GetTag returns a tag and its snapshot metadata.
+func (c *Catalog) GetTag(id Identifier, tagName string) (GetTagResponse,
error) {
+ if c.inner == nil {
+ return GetTagResponse{}, ErrClosed
+ }
+ cID, err := c.newIdentifier(id)
+ if err != nil {
+ return GetTagResponse{}, err
+ }
+ defer ffiIdentifierFree.symbol(c.ctx)(cID)
+ return ffiCatalogGetTag.symbol(c.ctx)(c.inner, cID, tagName)
+}
+
+// DeleteTag deletes a tag.
+func (c *Catalog) DeleteTag(id Identifier, tagName string) error {
+ if c.inner == nil {
+ return ErrClosed
+ }
+ cID, err := c.newIdentifier(id)
+ if err != nil {
+ return err
+ }
+ defer ffiIdentifierFree.symbol(c.ctx)(cID)
+ return ffiCatalogDeleteTag.symbol(c.ctx)(c.inner, cID, tagName)
+}
+
+var ffiCatalogCreateTag = newFFI(ffiOpts{
+ sym: "paimon_catalog_create_tag",
+ rType: &ffi.TypePointer,
+ aTypes: []*ffi.Type{
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ &ffi.TypeUint8,
+ },
+}, func(ctx context.Context, ffiCall ffiCall) func(
+ *paimonCatalog,
+ *paimonIdentifier,
+ string,
+ *int64,
+ bool,
+) error {
+ return func(
+ catalog *paimonCatalog,
+ id *paimonIdentifier,
+ tagName string,
+ snapshotID *int64,
+ ignoreIfExists bool,
+ ) error {
+ tagNamePtr, err := bytePtrFromString(tagName)
+ if err != nil {
+ return err
+ }
+ var snapshotIDPtr unsafe.Pointer
+ if snapshotID != nil {
+ snapshotIDPtr = unsafe.Pointer(snapshotID)
+ }
+ ignore := uint8(0)
+ if ignoreIfExists {
+ ignore = 1
+ }
+ var ffiError *paimonError
+ ffiCall(
+ unsafe.Pointer(&ffiError),
+ unsafe.Pointer(&catalog),
+ unsafe.Pointer(&id),
+ unsafe.Pointer(&tagNamePtr),
+ unsafe.Pointer(&snapshotIDPtr),
+ unsafe.Pointer(&ignore),
+ )
+ runtime.KeepAlive(tagNamePtr)
+ runtime.KeepAlive(snapshotID)
+ return parseError(ctx, ffiError)
+ }
+})
+
+var ffiTableLatestSnapshot = newFFI(ffiOpts{
+ sym: "paimon_table_latest_snapshot",
+ rType: &typeResultLatestSnapshot,
+ aTypes: []*ffi.Type{&ffi.TypePointer},
+}, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable) (*Snapshot,
error) {
+ return func(table *paimonTable) (*Snapshot, error) {
+ var result resultLatestSnapshot
+ ffiCall(unsafe.Pointer(&result), unsafe.Pointer(&table))
+ if result.error != nil {
+ return nil, parseError(ctx, result.error)
+ }
+ defer ffiBytesFree.symbol(ctx)(result.snapshot)
+ var snapshot *Snapshot
+ if err := json.Unmarshal(parseBytes(result.snapshot),
&snapshot); err != nil {
+ return nil, err
+ }
+ return snapshot, nil
+ }
+})
+
+var ffiCatalogGetTag = newFFI(ffiOpts{
+ sym: "paimon_catalog_get_tag",
+ rType: &typeResultGetTag,
+ aTypes: []*ffi.Type{&ffi.TypePointer, &ffi.TypePointer,
&ffi.TypePointer},
+}, func(ctx context.Context, ffiCall ffiCall) func(
+ *paimonCatalog,
+ *paimonIdentifier,
+ string,
+) (GetTagResponse, error) {
+ return func(catalog *paimonCatalog, id *paimonIdentifier, tagName
string) (GetTagResponse, error) {
+ tagNamePtr, err := bytePtrFromString(tagName)
+ if err != nil {
+ return GetTagResponse{}, err
+ }
+ var result resultGetTag
+ ffiCall(
+ unsafe.Pointer(&result),
+ unsafe.Pointer(&catalog),
+ unsafe.Pointer(&id),
+ unsafe.Pointer(&tagNamePtr),
+ )
+ runtime.KeepAlive(tagNamePtr)
+ if result.error != nil {
+ return GetTagResponse{}, parseError(ctx, result.error)
+ }
+ defer ffiBytesFree.symbol(ctx)(result.tag)
+ var tag GetTagResponse
+ if err := json.Unmarshal(parseBytes(result.tag), &tag); err !=
nil {
+ return GetTagResponse{}, err
+ }
+ return tag, nil
+ }
+})
+
+var ffiCatalogDeleteTag = newFFI(ffiOpts{
+ sym: "paimon_catalog_delete_tag",
+ rType: &ffi.TypePointer,
+ aTypes: []*ffi.Type{
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ },
+}, func(ctx context.Context, ffiCall ffiCall) func(
+ *paimonCatalog,
+ *paimonIdentifier,
+ string,
+) error {
+ return func(
+ catalog *paimonCatalog,
+ id *paimonIdentifier,
+ tagName string,
+ ) error {
+ tagNamePtr, err := bytePtrFromString(tagName)
+ if err != nil {
+ return err
+ }
+ var ffiError *paimonError
+ ffiCall(
+ unsafe.Pointer(&ffiError),
+ unsafe.Pointer(&catalog),
+ unsafe.Pointer(&id),
+ unsafe.Pointer(&tagNamePtr),
+ )
+ runtime.KeepAlive(tagNamePtr)
+ return parseError(ctx, ffiError)
+ }
+})
+
+var ffiBytesFree = newFFI(ffiOpts{
+ sym: "paimon_bytes_free",
+ rType: &ffi.TypeVoid,
+ aTypes: []*ffi.Type{&typePaimonBytes},
+}, func(_ context.Context, ffiCall ffiCall) func(paimonBytes) {
+ return func(value paimonBytes) {
+ ffiCall(nil, unsafe.Pointer(&value))
+ }
+})
diff --git a/bindings/go/tests/catalog_tag_test.go
b/bindings/go/tests/catalog_tag_test.go
new file mode 100644
index 00000000..0a84beb7
--- /dev/null
+++ b/bindings/go/tests/catalog_tag_test.go
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+package paimon_test
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+
+ paimon "github.com/apache/paimon-rust/bindings/go"
+)
+
+func TestCatalogTagLifecycle(t *testing.T) {
+ source := filepath.Join("testdata", "map_blob_table")
+ warehouse := t.TempDir()
+ if err := copyDirectory(source, filepath.Join(warehouse, "default.db",
"map_blob_table")); err != nil {
+ t.Fatal(err)
+ }
+ catalog, err := paimon.NewCatalog(map[string]string{"warehouse":
warehouse})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer catalog.Close()
+
+ id := paimon.NewIdentifier("default", "map_blob_table")
+ table, err := catalog.GetTable(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer table.Close()
+ latest, err := table.LatestSnapshot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if latest == nil || latest.ID != 1 {
+ t.Fatalf("latest snapshot = %#v", latest)
+ }
+
+ if err := catalog.CreateTag(id, "release-1", nil, false); err != nil {
+ t.Fatal(err)
+ }
+ tag, err := catalog.GetTag(id, "release-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tag.TagName != "release-1" || tag.Snapshot.ID != 1 {
+ t.Fatalf("unexpected tag: %#v", tag)
+ }
+ if tag.Snapshot.CommitKind != paimon.CommitKindAppend {
+ t.Fatalf("commit kind = %q, want %q", tag.Snapshot.CommitKind,
paimon.CommitKindAppend)
+ }
+ readBuilder, err := table.NewReadBuilderWithOptions(map[string]string{
+ "scan.tag-name": "release-1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ readBuilder.Close()
+
+ snapshotID := int64(1)
+ if err := catalog.CreateTag(id, "release-explicit", &snapshotID,
false); err != nil {
+ t.Fatal(err)
+ }
+
+ err = catalog.CreateTag(id, "release-1", nil, false)
+ var paimonErr *paimon.Error
+ if !errors.As(err, &paimonErr) || paimonErr.Code() !=
paimon.CodeAlreadyExist {
+ t.Fatalf("duplicate tag error = %v", err)
+ }
+ if err := catalog.CreateTag(id, "release-1", nil, true); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := catalog.DeleteTag(id, "release-1"); err != nil {
+ t.Fatal(err)
+ }
+ _, err = catalog.GetTag(id, "release-1")
+ if !errors.As(err, &paimonErr) || paimonErr.Code() !=
paimon.CodeNotFound {
+ t.Fatalf("missing tag error = %v", err)
+ }
+ err = catalog.DeleteTag(id, "release-1")
+ if !errors.As(err, &paimonErr) || paimonErr.Code() !=
paimon.CodeNotFound {
+ t.Fatalf("delete missing tag error = %v", err)
+ }
+ if err := catalog.DeleteTag(id, "release-explicit"); err != nil {
+ t.Fatal(err)
+ }
+
+ catalog.Close()
+ if err := catalog.CreateTag(id, "closed", nil, false); !errors.Is(err,
paimon.ErrClosed) {
+ t.Fatalf("closed catalog error = %v", err)
+ }
+}
diff --git a/bindings/go/types.go b/bindings/go/types.go
index 6adbac94..5412bcd6 100644
--- a/bindings/go/types.go
+++ b/bindings/go/types.go
@@ -74,6 +74,15 @@ var (
}[0],
}
+ typePaimonBytes = ffi.Type{
+ Type: ffi.Struct,
+ Elements: &[]*ffi.Type{
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ nil,
+ }[0],
+ }
+
typeResultReadBlobs = ffi.Type{
Type: ffi.Struct,
Elements: &[]*ffi.Type{
@@ -105,6 +114,28 @@ var (
}[0],
}
+ // paimon_result_get_tag { tag: paimon_bytes, error: *paimon_error }
+ typeResultGetTag = ffi.Type{
+ Type: ffi.Struct,
+ Elements: &[]*ffi.Type{
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ nil,
+ }[0],
+ }
+
+ // paimon_result_latest_snapshot { snapshot: paimon_bytes, error:
*paimon_error }
+ typeResultLatestSnapshot = ffi.Type{
+ Type: ffi.Struct,
+ Elements: &[]*ffi.Type{
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ &ffi.TypePointer,
+ nil,
+ }[0],
+ }
+
// paimon_result_identifier_new { identifier: paimon_identifier, error:
*paimon_error }
typeResultIdentifierNew = ffi.Type{
Type: ffi.Struct,
@@ -370,6 +401,16 @@ type resultGetTable struct {
error *paimonError
}
+type resultGetTag struct {
+ tag paimonBytes
+ error *paimonError
+}
+
+type resultLatestSnapshot struct {
+ snapshot paimonBytes
+ error *paimonError
+}
+
type resultIdentifierNew struct {
identifier *paimonIdentifier
error *paimonError
diff --git a/docs/src/go-binding.md b/docs/src/go-binding.md
index 40acda2d..025f8e54 100644
--- a/docs/src/go-binding.md
+++ b/docs/src/go-binding.md
@@ -292,6 +292,44 @@ catalog, err := paimon.NewCatalog(map[string]string{
})
```
+## Managing Tags
+
+Catalog tags pin table snapshots. Pass `nil` to tag the latest snapshot, then
+read the tag to record the snapshot ID:
+
+```go
+id := paimon.NewIdentifier("default", "my_table")
+if err := catalog.CreateTag(id, "dataset-42", nil, false); err != nil {
+ log.Fatal(err)
+}
+tag, err := catalog.GetTag(id, "dataset-42")
+if err != nil {
+ log.Fatal(err)
+}
+log.Printf("pinned snapshot %d", tag.Snapshot.ID)
+```
+
+Use `table.LatestSnapshot()` when the current snapshot must be inspected before
+tagging; it returns `nil` for an empty table.
+
+Read the pinned snapshot with the existing read options:
+
+```go
+builder, err := table.NewReadBuilderWithOptions(map[string]string{
+ "scan.tag-name": "dataset-42",
+})
+```
+
+To tag a specific snapshot, pass its ID:
+
+```go
+snapshotID := int64(123)
+err := catalog.CreateTag(id, "dataset-43", &snapshotID, false)
+```
+
+The final argument to `CreateTag` ignores an existing tag. Creating tags with
+a retention duration is not yet supported.
+
## Writing a Table
Use `NewWriteBuilder` for ordinary and fixed-bucket tables. The `arrow.Record`