gabriel / muse public
symbol_cache.py python
185 lines 6.8 KB
Raw
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 71 days ago
1 """Persistent symbol-tree cache — eliminates re-parsing on every CLI invocation.
2
3 Architecture
4 ------------
5 Every call to ``symbols_for_snapshot`` currently re-parses all semantic files
6 from their raw bytes: ~1,300 ms for a 400-file Python codebase. The parse
7 result is fully determined by the file content — the same bytes always produce
8 the same ``SymbolTree``.
9
10 ``SymbolCache`` exploits this by persisting the parse results keyed by the
11 SHA-256 of the file bytes (``object_id`` from the content-addressed object
12 store, or a freshly computed SHA-256 for working-tree reads). On a warm cache
13 hit the parse step is skipped entirely; the full snapshot loads in ~22 ms
14 instead of ~1,300 ms — a 60× speedup.
15
16 Cache key
17 ---------
18 The key is the 64-character lowercase SHA-256 hex digest of the **raw file
19 bytes**, which is:
20
21 * For committed files — the ``object_id`` from the snapshot manifest
22 (content-addressed, so it is already the SHA-256 of the bytes).
23 * For working-tree files — ``hashlib.sha256(raw_bytes).hexdigest()``,
24 computed from the bytes already read from disk.
25
26 Because the key is content-addressed, every hit is guaranteed correct.
27 A file edit produces a new SHA-256 → cache miss → fresh parse → new entry.
28 Old entries for changed files become dead weight but never produce wrong
29 results.
30
31 Storage
32 -------
33 ``.muse/cache/symbols.json`` — a plain JSON document::
34
35 {
36 "version": 2,
37 "entries": {
38 "<sha256-hex>": {
39 "<file::address>": {
40 "kind": "function",
41 "name": "run",
42 ...
43 }
44 }
45 }
46 }
47
48 Writes are atomic (Pattern A): ``mkstemp`` gives each writer a unique temp
49 file so two concurrent saves cannot interleave bytes; ``os.replace`` is the
50 atomic rename.
51
52 Pruning
53 -------
54 Entries accumulate as new file versions are committed. The cache is naturally
55 bounded by the object store: once ``muse gc`` collects an object, its symbol
56 cache entry is dead weight. A future ``muse gc`` integration can prune the
57 cache via :meth:`SymbolCache.prune`. Until then, the cache grows at the rate
58 of unique file-content changes — typically kilobytes per commit.
59 """
60
61 import hashlib
62 import pathlib
63 from typing import TypeGuard, get_args
64
65 from muse.core.cache_base import MsgpackCache, _RawCacheMap
66 from muse.core.types import MsgpackValue
67 from muse.plugins.code.ast_parser import SymbolKind, SymbolRecord, SymbolTree
68
69 type _SymbolTreeMap = dict[str, "SymbolTree"]
70
71 _CACHE_VERSION = 2
72 _CACHE_FILENAME = "symbols.json"
73
74 # All string fields in SymbolRecord, used for structural validation on load.
75 _STR_FIELDS: frozenset[str] = frozenset({
76 "kind", "name", "qualified_name", "content_id",
77 "body_hash", "signature_id", "metadata_id", "canonical_key",
78 })
79 _INT_FIELDS: frozenset[str] = frozenset({"lineno", "end_lineno"})
80 _VALID_KINDS: frozenset[str] = frozenset(get_args(SymbolKind))
81
82 def _object_id_of(raw: bytes) -> str:
83 """Return the content-addressed ``sha256:`` ID of *raw* bytes."""
84 from muse.core.types import blob_id
85 return blob_id(raw)
86
87 def _is_symbol_record(obj: MsgpackValue) -> TypeGuard[SymbolRecord]:
88 """Return ``True`` if *obj* is structurally a valid ``SymbolRecord``.
89
90 Used during cache deserialization to validate entries from JSON without
91 constructing a new dict — the validated dict IS the SymbolRecord at runtime.
92 """
93 if not isinstance(obj, dict):
94 return False
95 for field in _STR_FIELDS:
96 if not isinstance(obj.get(field), str):
97 return False
98 for field in _INT_FIELDS:
99 if not isinstance(obj.get(field), int):
100 return False
101 return obj["kind"] in _VALID_KINDS
102
103 class SymbolCache(MsgpackCache):
104 """Persistent JSON cache mapping object_id → SymbolTree.
105
106 Inherits load/save/get/put/prune/size/empty from :class:`MsgpackCache`
107 (Pattern A — mkstemp + replace).
108
109 Typical lifecycle inside ``symbols_for_snapshot``::
110
111 cache = SymbolCache.load(muse_dir)
112 for file_path, object_id in manifest.items():
113 tree = cache.get(object_id)
114 if tree is None:
115 raw = read_object(root, object_id)
116 tree = parse_symbols(raw, file_path)
117 cache.put(object_id, tree)
118 cache.save()
119
120 Attributes
121 ----------
122 _cache_dir : pathlib.Path | None
123 Absolute path to ``.muse/cache/``. ``None`` for in-memory-only
124 instances (``empty()``). ``save()`` is a no-op when ``None``.
125 _dirty : bool
126 Set to ``True`` by ``put()`` and ``prune()`` when entries change.
127 Reset to ``False`` by a successful ``save()``.
128 """
129
130 _CACHE_FILENAME = "symbols.json"
131 _CACHE_VERSION = 2
132 _TEMP_PREFIX = ".symbols_"
133
134 @classmethod
135 def _deserialize_entries(cls, raw: _RawCacheMap) -> _RawCacheMap:
136 """Validate and convert raw JSON entries to a typed SymbolTree map.
137
138 Each entry is a dict of address → SymbolRecord. Invalid entries (wrong
139 structure) are skipped so a partially corrupt file does not poison the
140 whole cache.
141 """
142 entries: _SymbolTreeMap = {}
143 for obj_id, tree_raw in raw.items():
144 if not isinstance(obj_id, str) or not isinstance(tree_raw, dict):
145 continue
146 tree: SymbolTree = {}
147 valid = True
148 for addr, rec_raw in tree_raw.items():
149 if not _is_symbol_record(rec_raw):
150 valid = False
151 break
152 tree[addr] = rec_raw
153 if valid:
154 entries[obj_id] = tree
155 return entries
156
157 def _serialize_entries(self) -> _RawCacheMap:
158 """Return ``_entries`` as-is — SymbolTree dicts are already JSON-serialisable."""
159 return self._entries # type: ignore[return-value]
160
161 # Type-narrowing overrides (correctness identical to base; annotations differ)
162
163 def get(self, object_id: str) -> SymbolTree | None:
164 """Return the cached ``SymbolTree`` for *object_id*, or ``None`` on miss."""
165 return self._entries.get(object_id) # type: ignore[return-value]
166
167 def put(self, object_id: str, tree: SymbolTree) -> None:
168 """Store *tree* under *object_id* and mark the cache dirty."""
169 super().put(object_id, tree)
170
171 def prune(self, live_ids: set[str]) -> None:
172 """Remove entries whose object IDs are not in *live_ids*.
173
174 Call this from ``muse gc`` after identifying all reachable object IDs
175 to keep the cache from growing unboundedly.
176 """
177 super().prune(live_ids)
178
179 def load_symbol_cache(root: pathlib.Path) -> SymbolCache:
180 """Convenience loader: return a ``SymbolCache`` for a repository root.
181
182 Returns ``SymbolCache.empty()`` when *root* has no ``.muse`` directory
183 so callers never need to guard against a missing repo.
184 """
185 return SymbolCache.from_root(root) # type: ignore[return-value]
File History 1 commit
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 71 days ago