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

nordisk/skade

tests/table_cache_budget.rs

// Apache-2.0 licensed.
//
// L1.5 built-`Table` handle cache: the guard that the cap is a BYTE budget and
// not an entry count.
//
// THE DEFECT THIS EXISTS TO CATCH. `moka`'s `max_capacity` counts ENTRIES unless
// the builder is given a `.weigher(...)`. `table_cache.rs` had no weigher and a
// cap of 512, while one entry pins a parsed `TableMetadata` (unbounded — it
// carries every snapshot and every property) plus iceberg's own per-`Table`
// `ObjectCache`, which defaults to 32 MiB. 512 × 32 MiB ≈ 16 GiB of manifest
// cache alone, held by a cache whose configured "capacity" read as a safe 512.
//
// HOW THIS TEST STAYS HONEST. The fixture deliberately contains entries whose
// sizes differ by ~an order of magnitude (a table carrying an 8 MiB property
// blob vs a table carrying none), because a guard built on an all-same-size
// fixture cannot tell a byte budget from an entry count — the identity-value
// trap. And the assertion is on RESIDENT BYTES, computed from what the test
// itself put in, never on moka's own `weighted_size()` alone: with the weigher
// removed, `weighted_size()` silently becomes the entry count and would sail
// under any byte budget while the process holds gigabytes.
//
// RED SEEN (2026-08-23): with `.weigher(...)` deleted from
// `TableHandleCache::new`, 2 of these 4 tests fail —
// `fat_entries_are_evicted_on_bytes_not_on_count` at "6 resident fat handles ×
// 12582912 B each is 75497472 B" (a 24 MiB budget holding 72 MiB), and
// `a_budget_below_one_handle_still_serves_correctly` at 3 resident vs 0.
// NOTE WHICH ASSERTION SAW IT. The `fat_entries < thin_entries` comparison did
// NOT fire under the removal (24 thin vs 6 fat is trivially true when the cache
// holds everything) — only the REAL-BYTES assertion below caught the defect.
// The comparison is kept because it is what proves the fixture is lopsided at
// all, but it is not the guard; the byte arithmetic is.

use std::collections::HashMap;
use std::sync::Arc;

use anyhow::Result;
use iceberg::io::LocalFsStorageFactory;
use iceberg::spec::{NestedField, PrimitiveType, Schema, Type};
use iceberg::{Catalog, CatalogBuilder, NamespaceIdent, TableCreation, TableIdent};
use skade_katalog::{RedbCatalogBuilder, TABLE_OBJECT_CACHE_BYTES};
use tempfile::TempDir;

/// The property blob that makes a "fat" table's metadata fat. `weight_of`
/// charges properties by `k.len() + v.len() + 32`, so this lands ~8 MiB of
/// metadata weight on top of the per-handle object-cache term.
const FAT_PROPERTY_BYTES: usize = 8 * 1024 * 1024;

/// The budget the guard runs under: room for a handful of fat handles, dozens of
/// thin ones. Deliberately NOT a round multiple of either.
const BUDGET: u64 = 24 * 1024 * 1024;

fn schema() -> Schema {
    Schema::builder()
        .with_schema_id(0)
        .with_fields(vec![
            NestedField::required(1, "k", Type::Primitive(PrimitiveType::String)).into(),
            NestedField::required(2, "v", Type::Primitive(PrimitiveType::Long)).into(),
        ])
        .build()
        .expect("schema")
}

async fn catalog(tmp: &TempDir, budget_bytes: u64) -> Result<skade_katalog::RedbCatalog> {
    let warehouse = tmp.path().join("warehouse");
    std::fs::create_dir_all(&warehouse)?;
    Ok(RedbCatalogBuilder::default()
        .db_path(tmp.path().join("catalog.redb"))
        .warehouse_location(format!("file://{}", warehouse.display()))
        .table_handle_cache_bytes(budget_bytes)
        .with_storage_factory(Arc::new(LocalFsStorageFactory))
        .load("skade", HashMap::new())
        .await?)
}

/// Create `n` tables named `<prefix>N`. When `fat`, each carries a multi-MiB
/// property blob so its parsed metadata really is an order of magnitude bigger.
async fn seed(
    cat: &skade_katalog::RedbCatalog,
    ns: &NamespaceIdent,
    prefix: &str,
    n: usize,
    fat: bool,
) -> Result<Vec<TableIdent>> {
    let mut out = Vec::new();
    for i in 0..n {
        let mut props = HashMap::new();
        if fat {
            props.insert("skade.test.ballast".to_string(), "x".repeat(FAT_PROPERTY_BYTES));
        }
        let creation = TableCreation::builder()
            .name(format!("{prefix}{i}"))
            .schema(schema())
            .properties(props)
            .build();
        cat.create_table(ns, creation).await?;
        out.push(TableIdent::new(ns.clone(), format!("{prefix}{i}")));
    }
    Ok(out)
}

/// **The fixture must actually be lopsided.** If a fat table and a thin table
/// weigh the same, every assertion below passes on a dead budget too. So prove
/// the spread first, in the cache's own currency: at the same budget, the cache
/// holds far fewer fat handles than thin ones.
#[tokio::test]
async fn fat_entries_are_evicted_on_bytes_not_on_count() -> Result<()> {
    let tmp = TempDir::new()?;
    let cat = catalog(&tmp, BUDGET).await?;
    let ns = NamespaceIdent::new("budget".to_string());
    cat.create_namespace(&ns, HashMap::new()).await?;

    // 24 thin tables: their metadata is a few KiB each, so what they cost is
    // essentially the per-handle object-cache ceiling.
    let thin = seed(&cat, &ns, "thin", 24, false).await?;
    for id in &thin {
        cat.load_table(id).await?;
    }
    let (thin_entries, thin_weight) = cat.table_handle_cache_stats().await;
    assert!(thin_entries > 0, "the cache must have admitted something");
    assert!(
        thin_weight <= BUDGET,
        "resident weight {thin_weight} must fit the {BUDGET} byte budget"
    );

    // 6 fat tables in a FRESH catalog (same budget, same disk), each ~8 MiB of
    // metadata on top of the same per-handle ceiling.
    let tmp2 = TempDir::new()?;
    let cat2 = catalog(&tmp2, BUDGET).await?;
    cat2.create_namespace(&ns, HashMap::new()).await?;
    let fat = seed(&cat2, &ns, "fat", 6, true).await?;
    for id in &fat {
        cat2.load_table(id).await?;
    }
    let (fat_entries, fat_weight) = cat2.table_handle_cache_stats().await;

    // FIXTURE CHECK, not the guard: proves the two populations really do differ
    // in weight. It passes under a count cap too (24 > 6 either way), which is
    // precisely why it cannot be the assertion that matters.
    assert!(
        fat_entries < thin_entries,
        "a byte budget must hold FEWER fat handles ({fat_entries}) than thin ones \
         ({thin_entries}) — equal counts mean the cap is still counting entries"
    );
    assert!(
        fat_weight <= BUDGET,
        "resident weight {fat_weight} must fit the {BUDGET} byte budget"
    );

    // THE GUARD. Residency bounded in REAL bytes, computed from what this test
    // put in — never from moka's own accounting, which degenerates to an entry
    // count the moment the weigher is gone and would then sail under any budget.
    // This is the assertion that goes red when the weigher is removed.
    let real_fat_bytes_each = FAT_PROPERTY_BYTES as u64 + TABLE_OBJECT_CACHE_BYTES;
    assert!(
        fat_entries * real_fat_bytes_each <= BUDGET * 2,
        "{fat_entries} resident fat handles × {real_fat_bytes_each} B each is \
         {} B — the cache is not bounded in bytes",
        fat_entries * real_fat_bytes_each
    );
    Ok(())
}

/// A budget below ONE entry must still be a budget: the cache admits nothing it
/// cannot afford, and `load_table` keeps answering correctly by rebuilding every
/// time. A cache that keeps its last entry no matter what has a floor, not a
/// bound — and that floor is exactly the unbounded-entry hole.
#[tokio::test]
async fn a_budget_below_one_handle_still_serves_correctly() -> Result<()> {
    let tmp = TempDir::new()?;
    // One handle costs at least the per-handle object-cache ceiling; half of it
    // cannot fit a single entry.
    let cat = catalog(&tmp, TABLE_OBJECT_CACHE_BYTES / 2).await?;
    let ns = NamespaceIdent::new("tiny".to_string());
    cat.create_namespace(&ns, HashMap::new()).await?;
    let ids = seed(&cat, &ns, "t", 3, false).await?;

    for id in &ids {
        let t = cat.load_table(id).await?;
        assert_eq!(t.identifier(), id, "an unaffordable cache must still SERVE");
        assert_eq!(
            t.metadata().current_schema().as_struct().fields().len(),
            2,
            "the served table must be the real one, not a husk"
        );
    }
    let (entries, weight) = cat.table_handle_cache_stats().await;
    assert_eq!(entries, 0, "nothing may be retained under a sub-entry budget");
    assert_eq!(weight, 0);
    Ok(())
}

/// `0` still disables the cache outright, and `load_table` still works.
#[tokio::test]
async fn zero_disables_the_cache() -> Result<()> {
    let tmp = TempDir::new()?;
    let cat = catalog(&tmp, 0).await?;
    let ns = NamespaceIdent::new("off".to_string());
    cat.create_namespace(&ns, HashMap::new()).await?;
    let ids = seed(&cat, &ns, "t", 2, false).await?;
    for id in &ids {
        assert_eq!(cat.load_table(id).await?.identifier(), id);
    }
    assert_eq!(cat.table_handle_cache_stats().await, (0, 0));
    Ok(())
}

/// The deprecated handle-COUNT knob still configures a working cache — it is
/// converted to the byte budget that count could have pinned in the worst case,
/// so an old config keeps a bound instead of silently losing one.
#[tokio::test]
async fn the_deprecated_count_knob_still_bounds() -> Result<()> {
    let tmp = TempDir::new()?;
    let warehouse = tmp.path().join("warehouse");
    std::fs::create_dir_all(&warehouse)?;
    let mut props = HashMap::new();
    props.insert("redb.table-handle-cache-capacity".to_string(), "4".to_string());
    let cat = RedbCatalogBuilder::default()
        .db_path(tmp.path().join("catalog.redb"))
        .warehouse_location(format!("file://{}", warehouse.display()))
        .with_storage_factory(Arc::new(LocalFsStorageFactory))
        .load("skade", props)
        .await?;
    let ns = NamespaceIdent::new("legacy".to_string());
    cat.create_namespace(&ns, HashMap::new()).await?;
    let ids = seed(&cat, &ns, "t", 3, false).await?;
    for id in &ids {
        cat.load_table(id).await?;
    }
    let (entries, weight) = cat.table_handle_cache_stats().await;
    assert!(entries > 0, "a count of 4 must still buy a usable cache");
    assert!(
        weight <= 4 * TABLE_OBJECT_CACHE_BYTES,
        "the converted budget must bound: {weight} B resident against \
         {} B",
        4 * TABLE_OBJECT_CACHE_BYTES
    );
    Ok(())
}