src/table_cache.rs
// Apache-2.0 licensed.
//! L1.5 built-`Table` handle cache.
//!
//! [`iceberg::Catalog::load_table`] returns an owned [`Table`]. Constructing one
//! via `Table::builder().build()` is dominated — by ~14 µs on this dev box,
//! ~99% of `load_table`'s warm cost — by a single thing: iceberg-rust builds a
//! fresh per-`Table` `ObjectCache` (a `moka` cache) on **every** call. That cost
//! is intrinsic to building the cache structure; `disable_cache()` does *not*
//! avoid it (it still constructs a capacity-0 `moka` cache, measured at the same
//! ~14 µs), and there is no public way to inject a pre-built cache.
//!
//! But [`Table`] derives [`Clone`], and cloning one shares the underlying
//! `Arc<ObjectCache>` — measured at **~100 ns**, ~140× cheaper than building.
//! So we build a `Table` once per `metadata_location` and hand out clones.
//!
//! Like the L0 [`crate::meta_cache`], the key is the **immutable,
//! content-addressed** `metadata_location`: a new commit writes a new metadata
//! file and a new key, so a cached handle can only be *evicted*, never *stale*.
//! Sharing one `ObjectCache` across the clones of a location is a bonus — it
//! turns iceberg's per-call throwaway manifest cache into a shared one.
use std::sync::Arc;
use iceberg::io::FileIO;
use iceberg::spec::TableMetadata;
use iceberg::table::Table;
use iceberg::{Result, TableIdent};
use moka::future::Cache;
/// Default budget for the built-`Table` handle cache (256 MiB), in BYTES.
///
/// **This used to be an entry count and that bounded nothing.** A `moka`
/// `max_capacity` with no `.weigher(...)` counts ENTRIES, and an entry here is
/// not a fixed-size thing: it pins its parsed `TableMetadata` (O(snapshots) —
/// hundreds of KB on a busy table) *plus* a scan-populated iceberg `ObjectCache`
/// whose own default budget is 32 MiB. The old cap of 512 handles was therefore
/// a licence to hold **512 × 32 MiB ≈ 16 GiB** of manifest cache alone, and the
/// module's own note recorded the symptom without naming the cause ("a generous
/// cap steadily pins gigabytes, observed on garmr's 8 GB SOC host") — while the
/// sibling [`crate::meta_cache`] eight files away already had the weigher.
///
/// So the cap is now BYTES, charged per handle at
/// `meta_cache::weight_of(metadata) + `[`TABLE_OBJECT_CACHE_BYTES`], and each
/// handle's `ObjectCache` is itself pinned to that byte budget. The sum is a
/// genuine upper bound on what this cache can pin, which an entry count never
/// was. `0` disables the cache.
pub const DEFAULT_TABLE_HANDLE_CACHE_BYTES: u64 = 256 * 1024 * 1024;
/// Per-handle budget for iceberg's own manifest/manifest-list `ObjectCache`.
///
/// iceberg-rust builds one `ObjectCache` per `Table` and defaults it to 32 MiB.
/// We build a `Table` per `metadata_location` and share it via clones, so this
/// figure multiplies by the number of RESIDENT handles — which is exactly why
/// the handle cache must charge it. 4 MiB keeps a hot table's manifest list plus
/// its manifests resident while making the worst case per handle small enough
/// that [`DEFAULT_TABLE_HANDLE_CACHE_BYTES`] still holds tens of tables.
pub const TABLE_OBJECT_CACHE_BYTES: u64 = 4 * 1024 * 1024;
/// Deprecated entry-count default. Kept so existing callers of
/// [`crate::builder::RedbCatalogBuilder::table_handle_cache_capacity`] compile;
/// a count is converted to a byte budget at the worst case per handle.
#[deprecated(
since = "0.9.11",
note = "an entry count cannot bound a cache whose entries are unbounded; use DEFAULT_TABLE_HANDLE_CACHE_BYTES"
)]
pub const DEFAULT_TABLE_HANDLE_CACHE_CAPACITY: u64 = 512;
/// Convert a legacy handle COUNT into the byte budget that count could have
/// pinned in the worst case. `0` stays `0` (disabled).
pub(crate) fn bytes_for_handle_count(count: u64) -> u64 {
count.saturating_mul(TABLE_OBJECT_CACHE_BYTES)
}
/// Byte-bounded, lock-free cache of built [`Table`] handles keyed by
/// `metadata_location`.
///
/// Cloneable and cheap to clone (shares one underlying store). A budget of `0`
/// disables it (every `load_table` builds a fresh `Table`).
#[derive(Clone)]
pub(crate) struct TableHandleCache {
inner: Option<Cache<String, Table>>,
}
impl TableHandleCache {
/// Build a handle cache bounded to roughly `max_bytes` of resident handles.
/// `0` disables it.
pub(crate) fn new(max_bytes: u64) -> Self {
let inner = if max_bytes == 0 {
None
} else {
Some(
Cache::builder()
.name("skade-katalog.table-handles")
.max_capacity(max_bytes)
.weigher(|_loc: &String, t: &Table| weight_of_handle(t.metadata()))
.build(),
)
};
Self { inner }
}
/// Clone of the cached [`Table`] for `metadata_location`, if present and its
/// identifier matches — letting `load_table` short-circuit before touching
/// the L0 metadata cache. An identifier mismatch (only possible when two
/// catalog entries alias the *same* metadata file, e.g. via `register_table`)
/// is treated as a miss.
pub(crate) async fn get(
&self,
metadata_location: &str,
identifier: &TableIdent,
) -> Option<Table> {
let hit = self.inner.as_ref()?.get(metadata_location).await?;
(hit.identifier() == identifier).then_some(hit)
}
/// `(resident handles, resident weighted bytes)` — the OBSERVABLE bound.
///
/// A byte budget nobody can read back is a byte budget nobody can prove, and
/// this is what the guard asserts against. Runs moka's pending maintenance
/// first so both figures are settled rather than eventually-consistent.
pub(crate) async fn stats(&self) -> (u64, u64) {
match &self.inner {
Some(c) => {
c.run_pending_tasks().await;
(c.entry_count(), c.weighted_size())
}
None => (0, 0),
}
}
/// Build a [`Table`] for `(identifier, metadata_location)` from already-loaded
/// metadata, cache it (keyed by the immutable location), and return it. Call
/// on a [`Self::get`] miss. With caching disabled this just builds.
pub(crate) async fn build_and_insert(
&self,
fileio: &FileIO,
identifier: &TableIdent,
metadata_location: String,
metadata: Arc<TableMetadata>,
) -> Result<Table> {
let built = build(fileio, identifier, metadata_location.clone(), metadata)?;
if let Some(cache) = &self.inner {
cache.insert(metadata_location, built.clone()).await;
}
Ok(built)
}
}
impl std::fmt::Debug for TableHandleCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.inner {
Some(c) => f
.debug_struct("TableHandleCache")
.field("entries", &c.entry_count())
.field("weighted_bytes", &c.weighted_size())
.finish(),
None => f
.debug_struct("TableHandleCache")
.field("enabled", &false)
.finish(),
}
}
}
/// What one resident handle is charged: its parsed metadata (the crate's ONE
/// metadata weigher, shared with [`crate::meta_cache`]) plus the ceiling of the
/// per-`Table` `ObjectCache` this module pins with `cache_size_bytes`.
///
/// The object-cache term is charged at its CEILING, not at what it currently
/// holds, deliberately: moka weighs an entry once, at admission, and the
/// `ObjectCache` fills up *afterwards*, as scans run through the handle. A
/// weigher that charged the current fill would under-count every entry the
/// moment it became useful — the same "an entry grows after admission" hole this
/// whole change exists to close.
fn weight_of_handle(meta: &TableMetadata) -> u32 {
crate::meta_cache::weight_of(meta).saturating_add(TABLE_OBJECT_CACHE_BYTES.min(u32::MAX as u64) as u32)
}
fn build(
fileio: &FileIO,
identifier: &TableIdent,
metadata_location: String,
metadata: Arc<TableMetadata>,
) -> Result<Table> {
// iceberg-rust main (2026-10-02 rebase) requires the runtime explicitly;
// every caller is on the tokio runtime the catalog serves from.
Table::builder()
.file_io(fileio.clone())
.identifier(identifier.clone())
.metadata_location(metadata_location)
.metadata(metadata)
// Pin iceberg's per-`Table` manifest cache. Without this every handle
// gets iceberg's 32 MiB default, and N resident handles pin 32N MiB that
// nothing in this crate can see or bound.
.cache_size_bytes(TABLE_OBJECT_CACHE_BYTES)
.runtime(iceberg::Runtime::try_current()?)
.build()
}