Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

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()
}