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