Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/catalog/rest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ chrono = { workspace = true }
http = { workspace = true }
iceberg = { workspace = true }
itertools = { workspace = true }
moka = { version = "0.12.10", features = ["sync"] }
reqwest = { workspace = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iceberg crate already pins moka (with the future feature), so I'd move the version into [workspace.dependencies] and use moka = { workspace = true } here rather than carry two independent pins. Not a blocker.

moka = { workspace = true, features = ["sync"] }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, thanks.

serde = { workspace = true }
serde_derive = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions crates/catalog/rest/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ pub const iceberg_catalog_rest::AUTH_TYPE_NONE: &str
pub const iceberg_catalog_rest::AUTH_TYPE_OAUTH2: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_AUTH_TYPE: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_DISABLE_HEADER_REDACTION: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_TABLE_CACHE_EXPIRE_AFTER_WRITE_MS: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_TABLE_CACHE_MAX_ENTRIES: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_URI: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_WAREHOUSE: &str
pub trait iceberg_catalog_rest::AuthManager: core::fmt::Debug + core::marker::Send + core::marker::Sync
Expand Down
211 changes: 203 additions & 8 deletions crates/catalog/rest/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::str::FromStr;
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use async_trait::async_trait;
use iceberg::encryption::kms::{KeyManagementClient, KmsClientFactory};
Expand Down Expand Up @@ -65,6 +66,17 @@ pub const REST_CATALOG_PROP_DISABLE_HEADER_REDACTION: &str = "disable-header-red
/// if a `token`, `credential` or `oauth2-server-uri` is configured, `none`
/// otherwise.
pub const REST_CATALOG_PROP_AUTH_TYPE: &str = "rest.auth.type";
/// Maximum number of loaded tables kept for freshness-aware loading (defaults
/// to 100; 0 disables the cache).
pub const REST_CATALOG_PROP_TABLE_CACHE_MAX_ENTRIES: &str = "rest-table-cache.max-entries";
/// How long a loaded table stays cached for freshness-aware loading, in
/// milliseconds (defaults to 5 minutes; must be positive and at most 1000 years).
pub const REST_CATALOG_PROP_TABLE_CACHE_EXPIRE_AFTER_WRITE_MS: &str =
"rest-table-cache.expire-after-write-ms";

/// Tables loaded with an `ETag`, keyed by session id and identifier. Stale
/// entries are harmless: the server answers `304` only while the `ETag` matches.
type TableCache = moka::sync::Cache<(String, TableIdent), (String, Table)>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "stale entries are harmless" claim leans harder than it looks. We invalidate nowhere — not on drop, rename, register, commit, or a 404 load — so a dropped-and-recreated table at the same ident is only caught if the server never reuses an ETag for recreated content. The spec treats the ETag as opaque, so a server deriving it from the metadata hash alone would 304 against the pre-drop entry and hand back a Table whose FileIO/credentials predate the recreate. Correctness otherwise holds (the server still 404s a dropped table), but the dropped table's Table stays pinned in memory until the TTL, and a 404 load leaves the stale If-None-Match in place instead of evicting.

Java invalidates by SessionIdTableId on drop/purge/rename/commit/register and on NoSuchTable. I'd add invalidate(&key) to delete_table, rename_table (src), register_table, update_table, and the 404 branch, and soften this doc line. One fork worth deciding: invalidate per-session like Java, or across sessions via invalidate_entries_if on the ident? Thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair point, the ETag is opaque. added invalidation on drop/purge, rename (source), register, commit and the 404 branch, and softened the doc. went per-session like Java: cross-session would need invalidate_entries_if, which has to be enabled up front and scans entries on each call.


const ICEBERG_REST_SPEC_VERSION: &str = "0.14.1";
const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
Expand Down Expand Up @@ -297,6 +309,36 @@ impl RestCatalogConfig {
.unwrap_or(false)
}

/// Builds the table cache for freshness-aware loading.
fn table_cache(&self) -> Result<TableCache> {
let parse = |key: &str, default: u64| -> Result<u64> {
self.props.get(key).map_or(Ok(default), |v| {
v.parse().map_err(|e| {
Error::new(ErrorKind::DataInvalid, format!("Invalid {key}: {v}")).with_source(e)
})
})
};
let expire_after_write = Duration::from_millis(parse(
REST_CATALOG_PROP_TABLE_CACHE_EXPIRE_AFTER_WRITE_MS,
300_000,
)?);
// Zero is ambiguous (disable or never expire), and moka panics beyond 1000 years.
if expire_after_write.is_zero()
|| expire_after_write > Duration::from_secs(1000 * 365 * 24 * 3600)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1000 * 365 * 24 * 3600 reads as a magic number and couples us to moka's internal 1000-year panic bound — I'd lift it to a named const MAX_TABLE_CACHE_TTL: Duration, alongside named consts for the 300_000 and 100 defaults below. While here, the error drops the offending value (unlike the parse error); including {expire_after_write:?} would make a bad config easier to spot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, thanks.

{
return Err(Error::new(
ErrorKind::DataInvalid,
format!(
"Invalid {REST_CATALOG_PROP_TABLE_CACHE_EXPIRE_AFTER_WRITE_MS}: must be between 1 ms and 1000 years"
),
));
}
Ok(TableCache::builder()
.max_capacity(parse(REST_CATALOG_PROP_TABLE_CACHE_MAX_ENTRIES, 100)?)
.time_to_live(expire_after_write)
.build())
}

/// Merge the `RestCatalogConfig` with the a [`CatalogConfig`] (fetched from the REST server).
pub(crate) fn merge_with_config(mut self, mut config: CatalogConfig) -> Self {
if let Some(uri) = config.overrides.remove(REST_CATALOG_PROP_URI) {
Expand Down Expand Up @@ -433,6 +475,8 @@ struct RestCatalogClient {
config: RestCatalogConfig,
/// Capabilities the server advertises (see [`RestSessionCatalog::supports_endpoint`]).
endpoints: HashSet<Endpoint>,
/// Tables reused when the server answers a load with `304 Not Modified`.
table_cache: TableCache,
}

impl Debug for RestCatalogClient {
Expand Down Expand Up @@ -481,6 +525,7 @@ impl RestCatalogClient {
Ok(Self {
auth_manager,
catalog_session,
table_cache: config.table_cache()?,
config,
http_client,
endpoints,
Expand Down Expand Up @@ -1250,18 +1295,33 @@ impl SessionCatalog for RestSessionCatalog {
table_ident: &TableIdent,
) -> Result<Table> {
let client = self.client().await?;
let cache_key = (context.session_id().to_string(), table_ident.clone());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth documenting that the cache is keyed on session_id, so it only helps when callers reuse a SessionContext — SessionContext::empty() mints a fresh id each call, so those callers get a 0% hit rate while still paying the key allocation and the table clone. Java keys the same way, so this is a doc note rather than a bug — I'd just call it out on the property constants so a multi-tenant proxy spinning a context per request knows what to expect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, thanks.

let cached = client.table_cache.get(&cache_key);

let request = HttpRequest::build(
client
.http_client
.request(Method::GET, client.config.table_endpoint(table_ident)),
)?;
let mut request_builder = client
.http_client
.request(Method::GET, client.config.table_endpoint(table_ident));
if let Some((etag, _)) = &cached {
request_builder = request_builder.header(header::IF_NONE_MATCH, etag);
}
let request = HttpRequest::build(request_builder)?;

let http_response = client.query_catalog(context, request).await?;
let etag = http_response
.headers()
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_string);

let response = match http_response.status() {
StatusCode::OK | StatusCode::NOT_MODIFIED => {
deserialize_catalog_response::<LoadTableResult>(http_response)?
StatusCode::OK => deserialize_catalog_response::<LoadTableResult>(http_response)?,
StatusCode::NOT_MODIFIED => {
return cached.map(|(_, table)| table).ok_or_else(|| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A 304 hands back the cached Table, and that Table carries a FileIO built from the earlier 200's config — including any vended s3.session-token / gcs.oauth2.token. The ETag only validates the metadata, and a 304 has no body to refresh credentials from, so on a hit we return storage credentials captured up to one TTL ago. With the 5-minute default that's usually under the STS lifetime, but the TTL is configurable up to 1000 years — any catalog vending tokens shorter than the TTL gets 403s from object storage on a "successful" load, and those surface from the FileIO, not the catalog, so they're painful to trace.

Java's RESTSessionCatalog doesn't serve a stale FileIO on a hit (apache/iceberg#14398) — please confirm the exact mechanism. The cleanest fix here is to cache the LoadTableResult config and rebuild the FileIO per hit; failing that, skip caching when the 200 carried credential-bearing config, and document that expire-after-write-ms must stay under the credential lifetime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. checked Java: on a hit it rebuilds the FileIO, but from the credentials captured at the original load (a 304 has no body). it stays fresh because VendedCredentialsProvider refreshes them via client.refresh-credentials-endpoint, and iceberg-rust has no refresh yet (#2932), so rebuilding per hit wouldn't help here. went with not caching loads that vend credentials (storage-credentials, or a secret-looking config key via is_sensitive_prop). we can revisit once refresh lands.

Error::new(
ErrorKind::Unexpected,
"Received 304 Not Modified for a table load without If-None-Match",
)
});
}
StatusCode::NOT_FOUND => {
return Err(Error::new(
Expand Down Expand Up @@ -1296,11 +1356,16 @@ impl SessionCatalog for RestSessionCatalog {
table_builder = table_builder.kms_client(kms_client);
}

if let Some(metadata_location) = response.metadata_location {
let table = if let Some(metadata_location) = response.metadata_location {
table_builder.metadata_location(metadata_location).build()
} else {
table_builder.build()
}?;

if let Some(etag) = etag {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a 200 that comes back without an ETag (a server that stopped sending it, or a proxy that strips it) we neither replace nor drop the existing entry, so every subsequent load keeps sending the stale If-None-Match. I'd add an else arm that invalidates:

if let Some(etag) = etag {
    client.table_cache.insert(cache_key, (etag, table.clone()));
} else {
    client.table_cache.invalidate(&cache_key);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, thanks.

client.table_cache.insert(cache_key, (etag, table.clone()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the cache is disabled (max-entries=0) we still allocate the key, clone the Table, and call insert here only for moka to drop it immediately — and the ETag string is built on every response, including 304s and errors. I'd store Option<TableCache> (None when max is 0) and skip the whole path, and move the ETag extraction into the OK arm so it's only built when we might actually cache. Non-blocking perf tidy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved the ETag extraction into the 200 arm. kept the cache non-optional though: moka already short-circuits get/insert at max_capacity 0, and with invalidation on several paths an Option would add a branch to each.

}
Ok(table)
}

/// Drop a table from the catalog.
Expand Down Expand Up @@ -4023,6 +4088,136 @@ mod tests {
rename_table_mock.assert_async().await;
}

/// Mocks one `loadTable` of `ns1.test1` that expects `if_none_match` and
/// answers `status`, carrying `etag` if given.
async fn load_table_mock(
server: &mut ServerGuard,
if_none_match: mockito::Matcher,
status: usize,
etag: Option<&str>,
) -> Mock {
let mut mock = server
.mock("GET", "/v1/namespaces/ns1/tables/test1")
.match_header("if-none-match", if_none_match)
.with_status(status);
if let Some(etag) = etag {
mock = mock.with_header("etag", etag);
}
if status == 200 {
mock = mock.with_body_from_file(format!(
"{}/testdata/load_table_response.json",
env!("CARGO_MANIFEST_DIR")
));
}
mock.create_async().await
}

fn test1() -> TableIdent {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test1() reads like a test rather than a fixture — test_table_ident() (or inlining it) would be clearer. Minor, and while in these tests mockito::Matcher gets spelled out in full repeatedly; a use would tidy them up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, thanks.

TableIdent::from_strs(["ns1", "test1"]).unwrap()
}

#[tokio::test]
async fn test_load_table_revalidates_cached_table_with_etag() {
let mut server = Server::new_async().await;
let _config = create_config_mock(&mut server).await;
let v1 = load_table_mock(&mut server, mockito::Matcher::Missing, 200, Some("\"v1\"")).await;
let reuse_v1 = load_table_mock(&mut server, "\"v1\"".into(), 304, None).await;
let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
let context = SessionContext::empty();

let loaded = catalog.load_table(&context, &test1()).await.unwrap();
let reused = catalog.load_table(&context, &test1()).await.unwrap();
assert_eq!(loaded.metadata_location(), reused.metadata_location());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both responses use the same load_table_response.json, so comparing metadata_location only proves a table came back — not that the cached table was the one returned, nor that the v2 200 actually replaced v1. I'd give v1 and v2 distinct metadata_locations and assert the reused table equals v1 after the 304 and v2 after the second 200.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, thanks.

v1.assert_async().await;
reuse_v1.assert_async().await;

reuse_v1.remove_async().await;
let v2 = load_table_mock(&mut server, "\"v1\"".into(), 200, Some("\"v2\"")).await;
let reuse_v2 = load_table_mock(&mut server, "\"v2\"".into(), 304, None).await;
catalog.load_table(&context, &test1()).await.unwrap();
catalog.load_table(&context, &test1()).await.unwrap();
v2.assert_async().await;
reuse_v2.assert_async().await;
}

#[tokio::test]
async fn test_load_table_not_modified_without_cached_table() {
let mut server = Server::new_async().await;
let _config = create_config_mock(&mut server).await;
let _load = load_table_mock(&mut server, mockito::Matcher::Missing, 304, None).await;
let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());

let err = catalog
.load_table(&SessionContext::empty(), &test1())
.await
.unwrap_err();

assert_eq!(err.kind(), ErrorKind::Unexpected);
}

#[tokio::test]
async fn test_table_cache_is_per_session() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passes even if caching were completely broken — two loads with no If-None-Match is exactly what test_table_cache_disabled_with_zero_max_entries asserts, so the two are indistinguishable. It also hinges on SessionContext::empty() handing out distinct ids; if that ever became deterministic the second load would send If-None-Match, the Missing matcher would miss, and mockito would 501. I'd make it discriminating: load in session A, load in session B and assert B sent no header, then load A again and assert it sent If-None-Match and got a 304 — with explicit session ids rather than relying on empty().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated, thanks.

let mut server = Server::new_async().await;
let _config = create_config_mock(&mut server).await;
let load = load_table_mock(&mut server, mockito::Matcher::Missing, 200, Some("\"v1\""))
.await
.expect(2);
let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());

catalog
.load_table(&SessionContext::empty(), &test1())
.await
.unwrap();
catalog
.load_table(&SessionContext::empty(), &test1())
.await
.unwrap();

load.assert_async().await;
}

#[tokio::test]
async fn test_table_cache_disabled_with_zero_max_entries() {
let mut server = Server::new_async().await;
let _config = create_config_mock(&mut server).await;
let load = load_table_mock(&mut server, mockito::Matcher::Missing, 200, Some("\"v1\""))
.await
.expect(2);
let catalog = session_catalog(
RestCatalogConfig::builder()
.uri(server.url())
.props(HashMap::from([(
REST_CATALOG_PROP_TABLE_CACHE_MAX_ENTRIES.to_string(),
"0".to_string(),
)]))
.build(),
);
let context = SessionContext::empty();

catalog.load_table(&context, &test1()).await.unwrap();
catalog.load_table(&context, &test1()).await.unwrap();

load.assert_async().await;
}

#[test]
fn test_table_cache_rejects_out_of_range_expiry() {
for value in ["0", "31536000000001"] {
let config = RestCatalogConfig::builder()
.uri("http://localhost".to_string())
.props(HashMap::from([(
REST_CATALOG_PROP_TABLE_CACHE_EXPIRE_AFTER_WRITE_MS.to_string(),
value.to_string(),
)]))
.build();

assert_eq!(
config.table_cache().unwrap_err().kind(),
ErrorKind::DataInvalid
);
}
}

#[tokio::test]
async fn test_load_table_404() {
let mut server = Server::new_async().await;
Expand Down
Loading