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

nordisk/skade

.nornir/read-path-design.md

The read path — design

Class: design · Function: read-path · Uses: store. Only the shape that runs; the why (why three layers, why no TTL, why foldhash) is in read-path-reasoning.md.

f0 — the layered resolve

A read resolves left to right, falling through only on a miss. The invariant that makes it coherence-free: every in-memory layer is keyed by an immutable, content-addressed identifier (a metadata_location or a snapshot id), so an entry can only be evicted, never stale.

table_exists / resolve_metadata / load_table
        │
   L1 pointer mirror ──miss──▶ L2 redb `tables`   (source of truth)
        │ (metadata_location)
   L1.5 Table handle cache ──miss──▶ L0 metadata cache ──miss──▶ FileIO read+parse

The warm-read entry points on RedbCatalog:

method source warm cost
table_exists catalog.rs:827 L1 hit, ~lock-free
resolve_location catalog.rs:107 L1 hit, no redb
resolve_metadata catalog.rs:99 L1 → L0, no Table build
load_table catalog.rs:790 L1 → L1.5 clone

f1 — L1: the pointer mirror (pointer_cache.rs)

PointerCache (pointer_cache.rs:60) is an ArcSwap<PointerSnapshot> wrapping an imbl::HashMap<String, Arc<str>, foldhash::fast::RandomState> (pointer_cache.rs:42). It maps table_key → metadata_location for every live table.

A key absent from the mirror means the table does not exist; the redb fallback in resolve_location / table_exists is a safety net that only runs on a genuine miss.

f2 — L0: the parsed-metadata cache (meta_cache.rs)

MetadataCache (meta_cache.rs:34) is a byte-bounded moka::future::Cache<String, Arc<TableMetadata>> keyed by metadata_location. get_or_load (meta_cache.rs:62) returns a cached Arc on a hit, else reads + parses the metadata JSON via FileIO, coalescing concurrent misses on the same location into one read_from (moka single-flight). Entry weight is a structural estimate (weight_of, meta_cache.rs:117); budget defaults to DEFAULT_METADATA_CACHE_BYTES = 128 MiB (meta_cache.rs:27). A budget of 0 disables it.

f3 — L1.5: the built-Table handle cache (table_cache.rs)

TableHandleCache (table_cache.rs:42) is a capacity-bounded moka::future::Cache<String, Table> keyed by metadata_location. get (table_cache.rs:67) returns a clone of a built Table (which shares the underlying Arc<ObjectCache>), short-circuiting before L0 — but only if the cached handle's identifier() matches, so two entries aliasing the same metadata file (via register_table) don't cross. build_and_insert (table_cache.rs:79) builds on a miss and caches. Default capacity DEFAULT_TABLE_HANDLE_CACHE_BYTES = 256 MiB (table_cache.rs); 0 disables. It is a BYTE budget, not a handle count: one handle pins its parsed TableMetadata plus iceberg's per-Table ObjectCache, so a count bounded nothing (512 handles x iceberg's 32 MiB default was ~16 GiB). Each handle's object cache is now pinned to TABLE_OBJECT_CACHE_BYTES (4 MiB) and charged at that ceiling.

Correctness

All three caches share one property: the key is the immutable metadata location (or an immutable snapshot id). A new commit writes a new file at a new location and flips the pointer, so a stale value is unrepresentable — no TTL, no invalidation, no coherence protocol. The full argument is in read-path-reasoning.md.