Skip to content

Caching

Overview

Floecat's read path is built around one observation: everything a query reads below the mutable pointer reads is an immutable blob. Caching therefore splits into a small number of disciplines, each with a different correctness contract, rather than one generic cache with invalidation callbacks.

Principles

  • Mutable edges resolve to immutable keys. A read first resolves a mutable pointer (resource ID → current blob URI), then follows content-addressed references. Only the pointer resolution can be stale; the content it names is immutable.
  • Content-addressed means no invalidation. The bytes at a CAS blob URI never change, so a decoded entry keyed by URI is right forever. Eviction exists only for memory, never correctness.
  • Caches serve content, never existence. A resident decode may outlive the durable blob (GC can sweep a superseded blob while its decode is still cached). Any read whose emptiness is load-bearing — a liveness or integrity probe — must hit the live store.

The four disciplines

Discipline Implementation What it holds Freshness contract
Versioned pointers PointerTtlCache (service/repo/cache/) Root pointer (TableRootRepository, floecat.root.pointer-cache-ttl-seconds, default 2) and table-definition metas (GraphCacheManager, floecat.metadata.graph.meta-cache-ttl-seconds, default 2) Short TTL bounds cross-instance staleness. Version-guarded populate (putIfFresher) keeps the highest pointer version; absence is never cached; writes invalidate then repopulate from a live read (read-your-writes on the writing instance). The root commit funnel bypasses the cache entirely via metaForSafeLive — CAS needs fresh versions. TTL 0 disables.
Immutable decoded content ImmutableBlobCache (service/repo/cache/) Decoded root blobs, snapshot-manifest pages, catalog/namespace/table/view/snapshot/constraints blobs, stats generation manifests, plus derived forms: manifest-entry indexes (headUri + "#index") and graph nodes (blobUri + "#node") Content-addressed, so no invalidation. Byte-weighted LRU: serializedSize × 3 retained-heap estimate, 256 MB default, 15-minute access TTL, single-flight loads, absence never cached. Config floecat.blob.cache.*; kill switch floecat.blob.cache.enabled=false. Only casBlobs schemas route through it — blobs overwritten in place must never be cached by URI.
Membership lists CatalogTopologyCache (service/metagraph/cache/) Namespace refs per catalog and relation (table/view) refs per namespace, from pointer prefix scans 15-minute access TTL (floecat.topology.cache-ttl-minutes; sizes floecat.topology.ns-cache-size=10000, floecat.topology.rel-cache-size=5000). DDL services evict on write, so same-process listings are immediately current; cross-instance staleness is bounded by the TTL.
Per-query state QueryContextStore and per-query memos QueryContext (pins, snapshot set, expansion map) keyed by query ID Scoped to one query lease; consistency comes from pinning, not freshness.

The ImmutableBlobCache budget is shared across all tenants. The previous design kept a dedicated node cache per account so eviction pressure was isolated between tenants; folding nodes into the process-wide cache trades that isolation for byte-accurate weighing, one budget to operate, and cross-layer sharing (a node and the pages it came from compete honestly for the same memory). One tenant's wide-schema or long-history scans can therefore evict another tenant's hot entries — softened by Caffeine's TinyLFU admission policy, which favors frequently reused entries over one-shot scan traffic. Operators should also note the knob-semantics change: floecat.metadata.graph.cache-max-size used to bound cached nodes per account; it now only gates node caching (0 = off) and sizes the pointer-meta cache, while node memory is governed by floecat.blob.cache.max-weight-bytes.

StatsOrchestrator additionally keeps its own byte-weighted 256 MB cache of decoded TargetStatsRecords, keyed by (accountId, tableId, snapshotId, storageId); only positive hits are cached and mutation paths invalidate explicitly (10-minute write TTL as a backstop). Target-stats record blobs are deliberately not in the ImmutableBlobCache: they are written to deterministic (not content-addressed) URIs and a re-capture may overwrite one in place, so URI-keyed caching would be unsound for them.

Deliberately live reads

Six reads bypass every cache because their result is a detector, not content:

Read Site Why it must be live
Pin-root HEAD PinValidator.validate The pin's integrity contract: one HEAD confirms the pinned root blob exists at the pinned version. The CAS GC min-age fence measures age since the blob was written, so a recently observed etag proves nothing about the blob still existing.
Frozen stats-manifest read StatsRepository.listTargetStatsInGeneration (per scan page) This read is the scan's retention guard. A cached generation ID would let a scan page "successfully" over a reclaimed generation — empty pages, silently truncated results — exactly when the guard must fire.
Pinned table + snapshot loads ScanBundleService.initScan requirePinned*'s contract is that a missing pinned blob fails as catalog-integrity corruption; a still-resident decode must not mask a swept blob. Once per scan session, not per page.
Pinned snapshot schema load SnapshotHelper.schemaJsonFor Same pin-integrity contract for the schema read from the pinned snapshot blob.
Pinned table-node load NodeLoader.tableFromBlob A query pinned to a blob must read that exact blob; a miss fails hard instead of drifting to current state.
Pinned constraints load PlannerStatsBundleService Emptiness is the broken-retention detector (pinned blobs are GC-rooted for the query's lifetime); a resident decode must not suppress the warning where cache warmth is highest.

The repository API encodes the split: getByBlobUri serves cached content — a present result does not prove the blob still exists — while getByBlobUriLive bypasses the cache for reads whose emptiness is load-bearing.

Staleness bounds

Observation Bound Governed by
Cross-instance DDL visibility (which blob a definition pointer names) ≤ 2 s Graph meta cache TTL (floecat.metadata.graph.meta-cache-ttl-seconds)
Table currency (which root is current) ≤ 2 s cross-instance; read-your-writes on the writing instance for update/create Root pointer cache TTL (floecat.root.pointer-cache-ttl-seconds) plus invalidate-then-live-refresh on writes
Catalog/namespace listings ≤ 15 min cross-instance; immediate same-process Topology cache TTL plus evict-on-write from DDL services
Pinned data read within a query None by construction Immutable blobs plus live integrity reads

Defaults for floecat.root.pointer-cache-ttl-seconds and floecat.blob.cache.* are set in service/src/main/resources/application.properties; the graph meta TTL and floecat.topology.* defaults come from their @ConfigProperty declarations (UserGraph, CatalogTopologyCache).