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 ff95db93 [core] Reuse supplied REST data token during native table
initialization (#1076)
ff95db93 is described below
commit ff95db9399a60828d74e032a64b6053ef039cc83
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Oct 8 20:49:05 2026 +0800
[core] Reuse supplied REST data token during native table initialization
(#1076)
---
.../python/python/pypaimon_rust/datafusion.pyi | 10 ++-
bindings/python/src/table.rs | 33 ++++++++-
.../paimon/src/catalog/rest/rest_token_file_io.rs | 67 +++++++++++++++++-
crates/paimon/src/table/rest_env.rs | 79 ++++++++++++++++++++--
4 files changed, 177 insertions(+), 12 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index ff4453ca..65f7090f 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -199,8 +199,16 @@ class Table:
@staticmethod
def from_rest_response(
response_json: str, *, database: str, table: str, rest_options:
Dict[str, str],
+ initial_data_token: Optional[Tuple[Dict[str, str], int]] = None,
) -> "Table":
- """Reuse matching REST metadata and merged catalog options."""
+ """Reuse REST metadata and optionally an already fetched table data
token."""
+ ...
+ @staticmethod
+ def from_rest_response_with_token(
+ response_json: str, *, database: str, table: str, rest_options:
Dict[str, str],
+ data_token: Dict[str, str], expires_at_millis: int,
+ ) -> "Table":
+ """Reuse REST metadata and a matching table token; Rust refreshes it
on expiry."""
...
def copy_with_resolved_schema(self, schema_json: str, *, branch:
Optional[str] = None) -> "Table":
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 7dbee4d3..2421a0ab 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -18,7 +18,7 @@
use std::collections::HashMap;
use std::sync::Arc;
-use paimon::catalog::Identifier;
+use paimon::catalog::{Identifier, RESTToken};
use paimon::io::FileIO;
use paimon::spec::TableSchema;
use paimon::Options;
@@ -90,13 +90,14 @@ impl PyTable {
/// Reuse the matching REST table response and merged catalog options.
/// Skips config/get-table requests, preserving REST snapshots and token
refresh.
#[staticmethod]
- #[pyo3(signature = (response_json, *, database, table, rest_options))]
+ #[pyo3(signature = (response_json, *, database, table, rest_options,
initial_data_token=None))]
fn from_rest_response(
py: Python<'_>,
response_json: &str,
database: &str,
table: &str,
rest_options: HashMap<String, String>,
+ initial_data_token: Option<(HashMap<String, String>, i64)>,
) -> PyResult<Self> {
let response: paimon::api::GetTableResponse =
serde_json::from_str(response_json).map_err(|err| {
@@ -104,18 +105,44 @@ impl PyTable {
})?;
let identifier = Identifier::new(database, table);
let rest_options = complete_storage_options(rest_options);
+ let initial_token =
+ initial_data_token.map(|(token, expires_at)| RESTToken::new(token,
expires_at));
let table = py
.detach(|| {
- runtime().block_on(paimon::table::Table::from_rest_response(
+
runtime().block_on(paimon::table::Table::from_rest_response_with_token(
identifier,
response,
Options::from_map(rest_options),
+ initial_token,
))
})
.map_err(to_py_err)?;
Ok(Self::new(Arc::new(table)))
}
+ /// Reuse an already obtained table token during REST table construction.
+ /// Rust keeps its normal refresh path when the token expires.
+ #[staticmethod]
+ #[pyo3(signature = (response_json, *, database, table, rest_options,
data_token, expires_at_millis))]
+ fn from_rest_response_with_token(
+ py: Python<'_>,
+ response_json: &str,
+ database: &str,
+ table: &str,
+ rest_options: HashMap<String, String>,
+ data_token: HashMap<String, String>,
+ expires_at_millis: i64,
+ ) -> PyResult<Self> {
+ Self::from_rest_response(
+ py,
+ response_json,
+ database,
+ table,
+ rest_options,
+ Some((data_token, expires_at_millis)),
+ )
+ }
+
/// Replace the complete schema while retaining FileIO, REST credentials
and branch.
/// The caller has already resolved fields and options; no schema is
reloaded.
#[pyo3(signature = (schema_json, *, branch=None))]
diff --git a/crates/paimon/src/catalog/rest/rest_token_file_io.rs
b/crates/paimon/src/catalog/rest/rest_token_file_io.rs
index 446a56ea..6b115621 100644
--- a/crates/paimon/src/catalog/rest/rest_token_file_io.rs
+++ b/crates/paimon/src/catalog/rest/rest_token_file_io.rs
@@ -52,6 +52,7 @@ pub struct RESTTokenFileIO {
path: String,
catalog_options: Options,
api: Arc<RESTApi>,
+ initial_token: Mutex<Option<RESTToken>>,
state: RwLock<Option<TokenState>>,
refresh_lock: Mutex<()>,
local_cache: Option<Arc<LocalCache>>,
@@ -81,6 +82,7 @@ impl RESTTokenFileIO {
path,
catalog_options,
api,
+ initial_token: Mutex::new(None),
state: RwLock::new(None),
refresh_lock: Mutex::new(()),
local_cache,
@@ -88,6 +90,13 @@ impl RESTTokenFileIO {
}
}
+ /// Reuse a token already obtained by a caller for this table. Normal REST
+ /// refresh remains available when that token approaches expiration.
+ pub(crate) fn with_initial_token(mut self, token: RESTToken) -> Self {
+ self.initial_token = Mutex::new(Some(token));
+ self
+ }
+
pub(crate) async fn build_file_io(self: &Arc<Self>) -> Result<FileIO> {
let file_io = self.current_file_io().await?;
Ok(file_io.with_provider(self.clone()))
@@ -103,7 +112,14 @@ impl RESTTokenFileIO {
return Ok(file_io);
}
- let token = self.refresh_token().await?;
+ let initial_token = self.initial_token.lock().await.take();
+ let token = match initial_token.as_ref() {
+ Some(token) if !Self::is_token_expired(token) => RESTToken::new(
+ self.merge_token_with_catalog_options(token.token.clone()),
+ token.expire_at_millis,
+ ),
+ _ => self.refresh_token().await?,
+ };
let file_io = self.build_static_file_io(&token)?;
*self.state.write().await = Some(TokenState {
token,
@@ -317,6 +333,55 @@ mod tests {
server.abort();
}
+ #[tokio::test]
+ async fn test_initial_token_skips_first_request_and_still_refreshes() {
+ let table_directory = tempfile::tempdir().unwrap();
+ let file_path = table_directory.path().join("data");
+ let (options, api, requests, server) = token_api().await;
+ let file_io_cache = file_io_cache(&options);
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_millis() as i64;
+ let token_file_io = Arc::new(
+ RESTTokenFileIO::new(
+ Identifier::new("database", "table"),
+ table_directory.path().to_string_lossy().into_owned(),
+ options,
+ api,
+ None,
+ file_io_cache,
+ )
+ .with_initial_token(RESTToken::new(
+ HashMap::new(),
+ now + TOKEN_EXPIRATION_SAFE_TIME_MILLIS * 2,
+ )),
+ );
+
+ let file_io = token_file_io.build_file_io().await.unwrap();
+ assert_eq!(requests.load(Ordering::SeqCst), 0);
+ assert!(!file_io
+ .exists(file_path.to_string_lossy().as_ref())
+ .await
+ .unwrap());
+ assert_eq!(requests.load(Ordering::SeqCst), 0);
+
+ token_file_io
+ .state
+ .write()
+ .await
+ .as_mut()
+ .unwrap()
+ .token
+ .expire_at_millis = 0;
+ assert!(!file_io
+ .exists(file_path.to_string_lossy().as_ref())
+ .await
+ .unwrap());
+ assert_eq!(requests.load(Ordering::SeqCst), 1);
+ server.abort();
+ }
+
#[tokio::test]
async fn test_blob_reader_reuses_refreshing_file_io() {
let table_directory = tempfile::tempdir().unwrap();
diff --git a/crates/paimon/src/table/rest_env.rs
b/crates/paimon/src/table/rest_env.rs
index 1b3375bb..fe811404 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -20,7 +20,7 @@
use crate::api::rest_api::RESTApi;
use crate::api::rest_error::RestError;
use crate::api::GetTableResponse;
-use crate::catalog::{Identifier, RESTTokenFileIO};
+use crate::catalog::{Identifier, RESTToken, RESTTokenFileIO};
use crate::common::{CatalogOptions, Options};
use crate::error::Error;
use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
@@ -38,6 +38,17 @@ impl Table {
identifier: Identifier,
response: crate::api::GetTableResponse,
rest_options: Options,
+ ) -> Result<Self> {
+ Self::from_rest_response_with_token(identifier, response,
rest_options, None).await
+ }
+
+ /// As `from_rest_response`, but reuse a matching table data token already
+ /// fetched by the caller. Rust will refresh it from REST after expiry.
+ pub async fn from_rest_response_with_token(
+ identifier: Identifier,
+ response: crate::api::GetTableResponse,
+ rest_options: Options,
+ initial_token: Option<RESTToken>,
) -> Result<Self> {
identifier.validate()?;
// Reject a stale or misrouted response before initializing auth or
local-cache resources.
@@ -55,7 +66,7 @@ impl Table {
.unwrap_or(false);
let local_cache = create_local_cache_with_namespace(&rest_options,
api.options())?;
let file_io_cache =
FileIOCacheContext::from_props(api.options().to_map())?;
- RESTEnv::build_table(
+ RESTEnv::build_table_with_token(
&identifier,
response,
api,
@@ -63,6 +74,7 @@ impl Table {
data_token_enabled,
local_cache,
file_io_cache,
+ initial_token,
)
.await
}
@@ -245,6 +257,31 @@ impl RESTEnv {
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
file_io_cache: FileIOCacheContext,
+ ) -> Result<Table> {
+ Self::build_table_with_token(
+ identifier,
+ response,
+ api,
+ options,
+ data_token_enabled,
+ local_cache,
+ file_io_cache,
+ None,
+ )
+ .await
+ }
+
+ // Keep the REST construction inputs explicit, including the optional
initial token.
+ #[allow(clippy::too_many_arguments)]
+ async fn build_table_with_token(
+ identifier: &Identifier,
+ response: crate::api::GetTableResponse,
+ api: Arc<RESTApi>,
+ options: Options,
+ data_token_enabled: bool,
+ local_cache: Option<Arc<LocalCache>>,
+ file_io_cache: FileIOCacheContext,
+ initial_token: Option<RESTToken>,
) -> Result<Table> {
let identifier = response_identifier(identifier, &response)?;
let schema = response.schema.ok_or_else(|| Error::DataInvalid {
@@ -303,7 +340,7 @@ impl RESTEnv {
source: None,
})?;
- let file_io = Self::build_file_io(
+ let file_io = Self::build_file_io_with_token(
&identifier,
&table_path,
api.clone(),
@@ -311,6 +348,7 @@ impl RESTEnv {
data_token_enabled && !is_external,
local_cache.clone(),
file_io_cache.clone(),
+ initial_token,
)
.await?;
@@ -406,18 +444,45 @@ impl RESTEnv {
use_data_token: bool,
local_cache: Option<Arc<LocalCache>>,
file_io_cache: FileIOCacheContext,
+ ) -> Result<FileIO> {
+ Self::build_file_io_with_token(
+ identifier,
+ path,
+ api,
+ options,
+ use_data_token,
+ local_cache,
+ file_io_cache,
+ None,
+ )
+ .await
+ }
+
+ // Match the existing FileIO builder while passing through the optional
initial token.
+ #[allow(clippy::too_many_arguments)]
+ async fn build_file_io_with_token(
+ identifier: &Identifier,
+ path: &str,
+ api: Arc<RESTApi>,
+ options: &Options,
+ use_data_token: bool,
+ local_cache: Option<Arc<LocalCache>>,
+ file_io_cache: FileIOCacheContext,
+ initial_token: Option<RESTToken>,
) -> Result<FileIO> {
if use_data_token {
- return Arc::new(RESTTokenFileIO::new(
+ let mut token_file_io = RESTTokenFileIO::new(
identifier.clone(),
path.to_string(),
options.clone(),
api,
local_cache,
file_io_cache,
- ))
- .build_file_io()
- .await;
+ );
+ if let Some(token) = initial_token {
+ token_file_io = token_file_io.with_initial_token(token);
+ }
+ return Arc::new(token_file_io).build_file_io().await;
}
let mut builder =
FileIO::from_path(path)?.with_props(options.to_map());