gabriel / muse public
cache-reorganization-plan.md markdown
329 lines 12.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago

Cache Reorganization Plan

Move all msgpack cache files from .muse/*.msgpack into .muse/cache/ and establish shared write utilities to eliminate duplicated idioms.

Motivation

The .muse/ root holds six cache files alongside authoritative VCS state (HEAD, refs, commits, config). Caches are recomputable and should not live next to irreplaceable data. Grouping them under .muse/cache/ makes the directory readable at a glance and enables future features (per-cache size limits, eviction policies, GC targeting).


Cache inventory

New path Module Size (typical) Write pattern
cache/test_history.msgpack muse/core/test_history.py 7 KB mktemp + replace
cache/stat.msgpack muse/core/stat_cache.py 150 KB mkstemp + fsync + replace
cache/symbols.msgpack muse/core/symbol_cache.py 35 MB fixed .tmp + replace
cache/callgraph.msgpack muse/core/callgraph_cache.py 11 MB fixed .tmp + replace
cache/implicit_edges.msgpack muse/core/implicit_edge_cache.py 154 KB fixed .tmp + replace
cache/invariants.msgpack muse/plugins/code/_invariants.py 19 MB fixed .tmp + replace

All caches are recomputable — data loss on upgrade or corruption is safe. Old files at the pre-migration locations become orphans on first use after upgrade and can be removed by muse gc or manually.


Write pattern taxonomy

Pattern A — mkstemp + fsync (StatCache) Safe for concurrent writers. mkstemp gives each writer a unique temp path so two parallel saves cannot collide. fsync ensures the bytes hit the platters before rename. Used only by StatCache.

Pattern B — fixed .tmp suffix (SymbolCache, CallGraphCache, ImplicitEdgeCache, _InvariantFileCache) Two writers produce the same .tmp name; one silently overwrites the other. Atomic for a single writer, but a race condition under parallel saves. Target: consolidated to Pattern A in Phase 5.

Pattern C — mktemp + replace (TestHistory) Unique temp path (no collision), no fsync. Acceptable for append-only history where a torn write is detected on next load.


Seven testing tiers

Every cache module must have tests in all seven tiers. The tier definitions below apply uniformly across Phases 1–6.

Tier 1 — Unit

Isolated, no filesystem I/O. Mocked or in-memory only.

Covers:

  • get() miss → None
  • put() → dirty flag set
  • get() hit after put()
  • prune() removes stale keys, sets dirty; no-op when all live
  • size property
  • empty()_cache_dir is None, no entries
  • save() on empty() is a no-op (no file created anywhere)

Tier 2 — Integration

Real filesystem via tmp_path. No subprocess, no CLI.

Covers:

  • load() on a missing file → empty cache
  • save() creates the file at the correct path under .muse/cache/
  • save() + load() round-trip: data identical after reload
  • save() is a no-op when not dirty; file mtime unchanged on second save
  • _dirty = False after a successful save
  • Fixture helper creates .muse/cache/ subdir before each test

Tier 3 — End-to-end

Full CLI invocation against a real repository built in tmp_path.

Covers:

  • Cold run populates the cache file on disk
  • Warm run reads from cache (observable via I/O patching or timing)
  • CLI output is identical between cold and warm runs
  • Cache file survives a process restart (saved and reloaded correctly)
  • muse gc removes orphaned old-location files (.muse/*.msgpack)

Tier 4 — Stress

Large inputs, high iteration counts. Not a performance gate — correctness under load.

Covers:

  • 500+ distinct entries written and read back without corruption
  • prune() on a 500-entry cache with 250 live keys: exact survivors
  • 1 000 put() calls with repeated keys: size == unique key count
  • Sequential save → load → save → load cycle: entries stable across 10 rounds

Tier 5 — Data integrity

Adversarial on-disk state: corruption, version mismatch, partial writes, cross-version upgrades.

Covers:

  • Corrupt msgpack bytes → load() returns empty, never raises
  • Wrong version field → load() returns empty
  • Missing entries key → load() returns empty
  • Invalid individual entry skipped; valid entries in same file survive
  • Non-string key in entries dict → entry skipped, rest survive
  • File truncated mid-write (simulated) → load() returns empty
  • Atomic write: no .tmp file left behind after a successful save()
  • Old-location orphan (cache_file.msgpack in .muse/) is ignored, not loaded

Tier 6 — Performance

Timing benchmarks. Assert speedup ratios, not absolute wall times, so CI hardware variance doesn't cause flakes.

Covers:

  • Warm cache ≥ 5× faster than cold for 30-file repo (same assertion style as existing callgraph and implicit-edge tests)
  • Second save() call (not dirty) completes in < 1 ms (no I/O)
  • load() of a 500-entry cache completes in < 200 ms

Tier 7 — Security

Untrusted cache content and path-traversal attempts.

Covers:

  • Relative .. path in a content-hash key → entry ignored on load
  • Symlink at the cache file path → save() does not follow it (writes to temp then replaces, so symlink is overwritten not followed)
  • Cache file owned by another user (mode 000) → load() returns empty, never raises; save() fails gracefully with a log warning, not a crash
  • Msgpack payload with deeply nested dicts (bomb) → load() returns empty within a bounded time (no unbounded recursion)
  • Field value of wrong type (e.g. int where str expected) → entry rejected, does not propagate into cache

Docstring standard

Every cache class and every public/private method that touches persistence must carry a docstring. Minimum structure:

class FooCache:
    """One-line summary.

    Longer description covering the cache key, value type, storage location,
    write pattern (Pattern A/B/C), and lifecycle.

    Attributes
    ----------
    _cache_dir : pathlib.Path | None
        Absolute path to ``.muse/cache/``.  ``None`` for in-memory-only
        instances (``empty()``).  ``save()`` is a no-op when ``None``.
    _dirty : bool
        Set to ``True`` by ``put()`` and ``prune()`` when entries change.
        Reset to ``False`` by a successful ``save()``.
    """

    @classmethod
    def load(cls, muse_dir: pathlib.Path) -> "FooCache":
        """Load from ``muse_dir/cache/<filename>``.

        Returns an empty cache on any failure — missing file, corrupt bytes,
        version mismatch — and never raises.  Invalid individual entries are
        skipped so a partially corrupt file does not poison the whole cache.

        Parameters
        ----------
        muse_dir:
            Path to the ``.muse/`` directory of the repository root.
        """

    def save(self) -> None:
        """Atomically persist the cache to disk if it has changed.

        Uses a temp-file-then-rename pattern so a crash mid-write never
        leaves a corrupt cache file.  Silently skips when ``_dirty`` is
        ``False`` or ``_cache_dir`` is ``None``.
        """

Phases

Phase 1 — Infrastructure + test_history ✅

  • Added cache_dir(root) helper to paths.py.muse/cache/
  • Updated test_history_path()cache_dir(root) / "test_history.msgpack"
  • Added "cache" to _INIT_SUBDIRS (init.py) and _MUSE_SWEEP_DIRS (repo.py)

Test gaps to backfill (tiers 3–7):

  • Tier 3: muse gc removes old .muse/test_history_cache.msgpack orphan
  • Tier 5: corrupt test_history.msgpack → graceful empty load
  • Tier 7: path-traversal key in history file → entry rejected

Phase 2 — stat_cache ✅

  • stat_cache_path(root)cache_dir(root) / "stat.msgpack"
  • StatCache stores _cache_dir; load() and save() use it
  • Temp files at .muse/cache/.stat_cache_*.tmp (Pattern A, already correct)
  • All tiers covered by test_core_stat_cache.py and test_query_stat_cache.py

Test gaps to backfill:

  • Tier 3 E2E: muse code symbols cold → warm CLI timing gap
  • Tier 7: symlink at cache/stat.msgpacksave() overwrites symlink, not target

Phase 3 — symbol_cache, callgraph_cache, implicit_edge_cache ✅

  • symbols.msgpack, callgraph.msgpack, implicit_edges.msgpack in cache/
  • All three cache classes store _cache_dir
  • Fixture helpers create .muse/cache/ subdir; 163 tests pass

Test gaps to backfill (all three modules):

  • Tier 4 stress: 500-entry put/prune correctness
  • Tier 7 security: .. in content-hash key rejected on load; mode-000 file → graceful load failure

Phase 4 — invariants cache

TDD order: write all seven test tiers first, then make them pass.

File: tests/test_invariant_file_cache.py

Production changes:

  • Add invariants_cache_path(root) to paths.pycache_dir(root) / "invariants.msgpack"
  • Rename _FILE_CACHE_NAME = "invariants.msgpack"
  • _InvariantFileCache.__init__: rename muse_dircache_dir; store self._cache_dir
  • load(repo_root): derive cache_dir = muse_dir / "cache"; return cls(cache_dir, ...)
  • save(): use self._cache_dir; call self._cache_dir.mkdir(parents=True, exist_ok=True)
  • Add full docstring to _InvariantFileCache, load(), save(), get(), put(), prune()

Test coverage required (all seven tiers):

Tier 1 — Unit

  • get() miss → None
  • put() → dirty
  • get() hit
  • prune() removes stale, sets dirty; no-op when all live
  • size property
  • empty()_cache_dir is None
  • save() on empty() → no file created

Tier 2 — Integration

  • load() missing file → empty
  • save() creates cache/invariants.msgpack
  • Round-trip: _FileData fields survive msgpack serialization intact
  • save() no-op when not dirty
  • _dirty = False after save
  • Fixture helper creates .muse/cache/ before each test

Tier 3 — End-to-end

  • Cold muse code invariants run creates cache/invariants.msgpack
  • Warm run: ast.parse not called (patch and assert call count == 0)
  • CLI output identical cold vs warm

Tier 4 — Stress

  • 500 distinct content hashes put and read back correctly
  • prune() on 500-entry cache with 250 live keys: exact survivors
  • 1 000 repeated put() calls with same key: size == 1

Tier 5 — Data integrity

  • Corrupt bytes → empty, no raise
  • Wrong version → empty
  • Missing entries → empty
  • Invalid entry skipped; valid entry in same file survives
  • Non-string content hash → entry skipped
  • No .tmp leftover after successful save
  • Old-location code_invariants_cache.msgpack in .muse/ is ignored

Tier 6 — Performance

  • Warm muse code invariants ≥ 5× faster than cold for 30-file repo
  • Not-dirty save() < 1 ms
  • load() of 500-entry cache < 200 ms

Tier 7 — Security

  • .. in content-hash key → entry rejected on load
  • Mode-000 cache file → load() returns empty, never raises
  • Deeply nested msgpack payload → load() returns empty within bounded time
  • Wrong-type field value → entry rejected

Phase 5 — Fix concurrent write race (Pattern B → A)

Replace fixed .tmp suffix writes with mkstemp in:

  • symbol_cache.py, callgraph_cache.py, implicit_edge_cache.py, _invariants.py

Add "cache" to _MUSE_SWEEP_DIRS prefix list so orphaned .tmp files under .muse/cache/ are swept on startup.

Test additions (Tier 7 security, Tier 5 integrity):

  • Concurrent save simulation: two writers, assert final file is valid msgpack (not a half-written interleaving)
  • Orphaned .tmp in .muse/cache/ is cleaned by muse gc

Phase 6 — Shared MsgpackCache base class

Extract common load() / _dirty / save() pattern into muse/core/cache_base.py.

MsgpackCache ABC:

  • _cache_path(root) -> Path — abstract
  • _serialize(entries) -> bytes — abstract
  • _deserialize(doc) -> entries — abstract
  • load(root) -> Self — classmethod, shared impl using Pattern A (mkstemp)
  • save() — shared impl

All Pattern-B caches inherit from it. StatCache keeps its custom fsync and dimension-hash logic.

Test additions:

  • All seven tiers applied to MsgpackCache itself via a minimal concrete subclass (_TestCache) defined inside the test file
  • Existing per-cache tier tests remain and continue to pass (no removal)

Migration for existing repos

Old cache files at .muse/*.msgpack become orphans on first run after upgrade. They are silently ignored — caches rebuild at the new location on next use. muse gc will sweep the old locations as a housekeeping step (non-blocking, added in Phase 4).

File History 1 commit
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago