Skip to content

✅ fresh

last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (cache)

Validation by Beadloom doc_sync — same source as sync-check.

Cache ​

Two-tier caching layer for context bundles: L1 in-memory dict and L2 SQLite persistent store.

Source: src/beadloom/context_oracle/cache.py

Specification ​

Purpose ​

Context bundle construction involves BFS graph traversal, chunk collection, code symbol lookup, and sync-state checks. The cache avoids repeating this work for identical requests. L1 provides zero-cost in-process lookups for the lifetime of the MCP server. L2 survives server restarts by persisting bundles in the SQLite bundle_cache table.

Cache Key ​

Both tiers key on the same four parameters that fully determine a context bundle:

python
CacheKey = tuple[str, int, int, int]  # (ref_id, depth, max_nodes, max_chunks)

L1 uses the tuple directly as a dict key. L2 serializes it to a string column cache_key.

L2 keys carry a fifth dimension: bundle_cache_key(..., with_intent=True) appends :intent. A bundle built with the project's TO-BE space read is a different bundle from one built without it, and sharing one entry would let a caller that opted out serve its not checked answer to a caller that asked. The suffix is appended only when intent was read, so every key a caller without it produces is byte-identical to the ones already stored.

Data Structures ​

CacheEntry (dataclass) ​

FieldTypeDescription
bundledict[str, Any]The full context bundle JSON structure
created_atfloattime.monotonic() timestamp at insertion
graph_mtimefloatmtime of graph directory at insertion time
docs_mtimefloatmtime of the docs directory and the TO-BE space at insertion time
created_at_isostrUTC ISO-8601 timestamp (default "")

Invalidation Algorithm ​

Both L1 and L2 use the same mtime-based invalidation strategy. No TTL is involved.

compute_bundle_mtimes(project_root) folds the TO-BE space into docs_mtime, because a bundle carries the intent recorded about its focus nodes and an edited CONTEXT.md therefore changes the bundle. A freshness input that does not cover every input to the answer is a cache that serves a stale one until something unrelated moves.

On get():

  1. Look up entry by cache key.
  2. If graph_mtime is provided and entry.graph_mtime < graph_mtime, the entry is stale -- delete it, return None.
  3. If docs_mtime is provided and entry.docs_mtime < docs_mtime, the entry is stale -- delete it, return None.
  4. If no mtimes are provided, return the cached value without staleness check.
  5. Otherwise, return the cached bundle.

On full reindex, both caches are cleared entirely via clear().

ETag Computation ​

python
def compute_etag(bundle: dict[str, Any]) -> str
  1. JSON-serialize the bundle with sort_keys=True, ensure_ascii=False.
  2. Compute SHA-256 of the UTF-8 encoded string.
  3. Truncate the hex digest to 16 characters.
  4. Return "sha256:<truncated_hex>".

The etag is stored in L2 and can be used by MCP clients for conditional requests.

L1: ContextCache ​

In-memory cache backed by a plain dict[CacheKey, CacheEntry].

Lifecycle ​

  • Created once per MCP server process.
  • Entries accumulate until a reindex triggers clear().
  • No size limit or eviction policy -- relies on reindex as the clearing event.

Methods ​

MethodSignatureDescription
get(ref_id, depth, max_nodes, max_chunks, *, graph_mtime=None, docs_mtime=None) -> dict | NoneReturn cached bundle or None on miss/stale
get_entry(ref_id, depth, max_nodes, max_chunks, *, graph_mtime=None, docs_mtime=None) -> CacheEntry | NoneReturn full CacheEntry or None on miss/stale
put(ref_id, depth, max_nodes, max_chunks, bundle, *, graph_mtime, docs_mtime) -> NoneStore a bundle with current monotonic + UTC timestamps
clear() -> NoneRemove all entries
clear_ref(ref_id: str) -> NoneRemove all entries whose key tuple has k[0] == ref_id
stats() -> dict[str, int]Return {"entries": <count>}

L2: SqliteCache ​

Persistent cache backed by the bundle_cache table in the beadloom SQLite database.

Table Schema ​

sql
CREATE TABLE IF NOT EXISTS bundle_cache (
    cache_key   TEXT PRIMARY KEY,
    bundle_json TEXT NOT NULL,
    etag        TEXT NOT NULL,
    graph_mtime REAL NOT NULL,
    docs_mtime  REAL NOT NULL,
    created_at  TEXT NOT NULL
);

Methods ​

MethodSignatureDescription
get(cache_key, *, graph_mtime=0.0, docs_mtime=0.0) -> tuple[dict, str, str] | NoneReturn (bundle, etag, created_at) or None. Deletes stale rows on read.
put(cache_key, bundle, *, graph_mtime, docs_mtime) -> NoneINSERT OR REPLACE with computed etag and UTC ISO timestamp
clear() -> NoneDELETE FROM bundle_cache
clear_ref(ref_id: str) -> NoneDELETE FROM bundle_cache WHERE cache_key LIKE '%<ref_id>%'

L2 Read-Through Behavior ​

When L2 get() detects a stale row (stored mtime < provided mtime), it deletes the row and commits immediately, then returns None. This means a stale L2 hit has the side effect of cleaning up the persistent store.

API ​

Public Functions ​

python
def compute_etag(bundle: dict[str, Any]) -> str

Compute a deterministic etag for a context bundle. Used by SqliteCache.put() and available for external consumers (e.g., MCP If-None-Match support).

Public Classes ​

python
class ContextCache:
    """L1 in-memory cache."""
    def __init__(self) -> None: ...
    def get(self, ref_id, depth, max_nodes, max_chunks, *, graph_mtime=None, docs_mtime=None) -> dict | None: ...
    def get_entry(self, ref_id, depth, max_nodes, max_chunks, *, graph_mtime=None, docs_mtime=None) -> CacheEntry | None: ...
    def put(self, ref_id, depth, max_nodes, max_chunks, bundle, *, graph_mtime, docs_mtime) -> None: ...
    def clear(self) -> None: ...
    def clear_ref(self, ref_id: str) -> None: ...
    def stats(self) -> dict[str, int]: ...

class SqliteCache:
    """L2 persistent cache."""
    def __init__(self, conn: sqlite3.Connection) -> None: ...
    def get(self, cache_key, *, graph_mtime=0.0, docs_mtime=0.0) -> tuple[dict, str, str] | None: ...
    def put(self, cache_key, bundle, *, graph_mtime, docs_mtime) -> None: ...
    def clear(self) -> None: ...
    def clear_ref(self, ref_id: str) -> None: ...

Invariants ​

  • A cache hit never returns a bundle produced from an older graph or docs mtime than the caller provides.
  • put() always overwrites any existing entry for the same key (L1 dict assignment, L2 INSERT OR REPLACE).
  • clear() leaves both caches completely empty -- no residual state.
  • compute_etag is deterministic: identical bundles produce identical etags.
  • L1 created_at uses time.monotonic(), not wall-clock time, to avoid clock-skew issues.

Constraints ​

  • No TTL-based expiration -- invalidation is strictly mtime-based.
  • L1 has no size limit. It relies on clear() being called on reindex. In long-running servers with diverse queries, memory usage grows linearly with distinct cache keys.
  • L2 clear_ref uses LIKE '%<ref_id>%' pattern matching, which is conservative: it may match and delete extra keys if ref_id is a substring of an unrelated cache key.
  • L2 commits after every put(), clear(), and stale-delete. There is no batching.
  • ETag truncation to 16 hex characters (64 bits) provides collision resistance sufficient for cache validation, not for cryptographic purposes.

Testing ​

Tests are located in tests/integration/context_oracle/cache/test_cache.py. Key scenarios:

  • L1 hit/miss: Verify get() returns the bundle after put(), and None for unknown keys.
  • Mtime invalidation: Verify that providing a newer graph_mtime or docs_mtime causes a miss and deletes the entry.
  • No-mtime passthrough: Verify that get() without mtime arguments returns the cached value regardless of stored mtimes.
  • clear() and clear_ref(): Verify complete and per-ref clearing.
  • get_entry(): Verify it returns the full CacheEntry with correct metadata.
  • stats(): Verify entry count reflects insertions and deletions.
  • L2 round-trip: Verify put() then get() returns identical bundle, correct etag, and ISO timestamp.
  • L2 stale delete: Verify stale reads delete the row and return None.
  • ETag determinism: Verify compute_etag produces the same result for the same input and different results for different inputs.