gabriel / muse public
cache_base.py python
254 lines 9.0 KB
Raw
sha256:5eff0848db1d0748f3a195ca871a9275e7455d447690411ec2d83434e11baa65 feat(#216): persist write-once BranchMeta creation provenance Human minor ⚠ breaking 2 days ago
1 """Shared base class for all Pattern-A JSON caches.
2
3 Subclasses set three class variables and implement two abstract methods;
4 ``load()`` and ``save()`` handle all disk I/O uniformly.
5
6 Pattern A — mkstemp + replace
7 ------------------------------
8 ``mkstemp`` gives each writer a process-unique temp file so two concurrent
9 saves cannot interleave bytes. ``os.replace`` is atomic on POSIX — the
10 final file is always a complete valid write, never a torn interleaving.
11
12 Usage
13 -----
14 ::
15
16 class MyCache(MsgpackCache):
17 _CACHE_FILENAME = "my.json"
18 _CACHE_VERSION = 2
19 _TEMP_PREFIX = ".my_cache_"
20
21 @classmethod
22 def _deserialize_entries(cls, raw: _RawCacheMap) -> _RawCacheMap:
23 # validate and return typed entries; skip invalid ones
24 ...
25
26 def _serialize_entries(self) -> _RawCacheMap:
27 # return JSON-compatible dict of self._entries
28 ...
29 """
30
31 import json as _json
32 import logging
33 import os
34 import pathlib
35 import tempfile
36 from abc import ABC, abstractmethod
37
38 from muse.core.paths import muse_dir as _muse_dir
39
40 logger = logging.getLogger(__name__)
41
42 type _MsgVal = str | int | float | bool | None | list["_MsgVal"] | dict[str, "_MsgVal"]
43 type _RawCacheMap = dict[str, _MsgVal]
44
45 class MsgpackCache(ABC):
46 """Abstract base for all Pattern-A JSON caches.
47
48 Subclasses define ``_CACHE_FILENAME``, ``_CACHE_VERSION``, ``_TEMP_PREFIX``
49 and implement ``_deserialize_entries`` / ``_serialize_entries``. Everything
50 else — ``load()``, ``save()``, ``get()``, ``put()``, ``prune()``,
51 ``size``, ``empty()`` — is provided here.
52
53 Attributes
54 ----------
55 _cache_dir : pathlib.Path | None
56 Absolute path to ``.muse/cache/``. ``None`` for in-memory-only
57 instances (``empty()``). ``save()`` is a no-op when ``None``.
58 _entries : dict
59 The live in-memory entries dict. Subclass provides the typed form
60 via ``_deserialize_entries`` on load and ``_serialize_entries`` on save.
61 _dirty : bool
62 Set to ``True`` by ``put()`` and ``prune()`` when entries change.
63 Reset to ``False`` by a successful ``save()``.
64 """
65
66 _CACHE_FILENAME: str
67 _CACHE_VERSION: int = 1
68 _TEMP_PREFIX: str
69
70 def __init__(
71 self,
72 cache_dir: pathlib.Path | None,
73 entries: _RawCacheMap,
74 ) -> None:
75 self._cache_dir = cache_dir
76 self._entries: _RawCacheMap = entries
77 self._dirty = False
78
79 # ------------------------------------------------------------------
80 # Abstract interface — subclasses implement these two methods only
81 # ------------------------------------------------------------------
82
83 @classmethod
84 @abstractmethod
85 def _deserialize_entries(cls, raw: _RawCacheMap) -> _RawCacheMap:
86 """Validate and convert raw JSON entries to typed entries.
87
88 Called by ``load()`` with the ``"entries"`` value from the file.
89 Invalid entries must be skipped — return only what is valid.
90 A partially corrupt file must not poison valid entries.
91 """
92
93 @abstractmethod
94 def _serialize_entries(self) -> _RawCacheMap:
95 """Convert ``self._entries`` to a JSON-serialisable dict.
96
97 Called by ``save()`` to build the ``"entries"`` value written to disk.
98 """
99
100 # ------------------------------------------------------------------
101 # Construction
102 # ------------------------------------------------------------------
103
104 @classmethod
105 def load(cls, muse_dir: pathlib.Path) -> "MsgpackCache":
106 """Load from ``muse_dir / "cache" / cls._CACHE_FILENAME``.
107
108 Returns an empty instance on any failure — missing file, corrupt bytes,
109 version mismatch — and never raises. Invalid individual entries are
110 skipped so a partially corrupt file does not poison the whole cache.
111
112 Parameters
113 ----------
114 muse_dir:
115 Path to the ``.muse/`` directory of the repository root.
116 """
117 cache_dir = muse_dir / "cache"
118 cache_file = cache_dir / cls._CACHE_FILENAME
119 if not cache_file.is_file():
120 return cls(cache_dir, {})
121 try:
122 raw = cache_file.read_bytes()
123 # Old binary msgpack files start with a byte > 0x7F. Treat as stale.
124 if raw and raw[0] > 0x7F:
125 logger.debug("⚠️ %s is old binary format — starting fresh", cls._CACHE_FILENAME)
126 return cls(cache_dir, {})
127 doc = _json.loads(raw.decode("utf-8"))
128 if not isinstance(doc, dict) or doc.get("version") != cls._CACHE_VERSION:
129 logger.debug(
130 "⚠️ %s version mismatch — starting fresh", cls._CACHE_FILENAME
131 )
132 return cls(cache_dir, {})
133 raw_entries = doc.get("entries")
134 if not isinstance(raw_entries, dict):
135 return cls(cache_dir, {})
136 entries = cls._deserialize_entries(raw_entries)
137 return cls(cache_dir, entries)
138 except Exception as exc: # noqa: BLE001
139 logger.debug(
140 "⚠️ %s unreadable (%s) — starting fresh", cls._CACHE_FILENAME, exc
141 )
142 return cls(cache_dir, {})
143
144 @classmethod
145 def empty(cls) -> "MsgpackCache":
146 """Return a no-op instance for contexts without a ``.muse`` directory.
147
148 ``save()`` is a no-op on instances returned by this method.
149 """
150 return cls(None, {})
151
152 @classmethod
153 def from_root(cls, root: pathlib.Path) -> "MsgpackCache":
154 """Load for a repository root, returning ``empty()`` when ``.muse/`` is absent.
155
156 Convenience alternative to calling ``load()`` directly. Handles the
157 common caller pattern of "give me a cache for this repo root, or a
158 no-op cache if it is not a Muse repository":
159
160 .. code-block:: python
161
162 cache = MyCache.from_root(root) # replaces:
163 # _dir = muse_dir(root)
164 # if _dir.is_dir():
165 # cache = MyCache.load(_dir)
166 # else:
167 # cache = MyCache.empty()
168
169 Parameters
170 ----------
171 root:
172 Repository root (the directory that contains ``.muse/``).
173 """
174 _dir = _muse_dir(root)
175 if _dir.is_dir():
176 return cls.load(_dir)
177 return cls.empty()
178
179 # ------------------------------------------------------------------
180 # Data access
181 # ------------------------------------------------------------------
182
183 def get(self, key: str) -> _MsgVal:
184 """Return the cached value for *key*, or ``None`` on miss."""
185 return self._entries.get(key)
186
187 def put(self, key: str, value: _MsgVal) -> None:
188 """Store *value* under *key* and mark the cache dirty."""
189 self._entries[key] = value
190 self._dirty = True
191
192 def prune(self, live_ids: set[str]) -> None:
193 """Remove entries whose keys are not in *live_ids*.
194
195 Sets ``_dirty`` only when at least one entry is removed.
196 """
197 stale = set(self._entries) - live_ids
198 if stale:
199 for k in stale:
200 del self._entries[k]
201 self._dirty = True
202 logger.debug(
203 "🗑️ %s pruned %d stale entries",
204 self.__class__.__name__,
205 len(stale),
206 )
207
208 @property
209 def size(self) -> int:
210 """Number of cached entries."""
211 return len(self._entries)
212
213 # ------------------------------------------------------------------
214 # Persistence
215 # ------------------------------------------------------------------
216
217 def save(self) -> None:
218 """Atomically persist the cache to disk if it has changed.
219
220 Uses ``mkstemp`` (``_TEMP_PREFIX``) for a process-unique temp file so
221 two concurrent writers cannot interleave bytes, then ``os.replace`` for
222 an atomic rename. Silently skips when ``_dirty`` is ``False`` or
223 ``_cache_dir`` is ``None``.
224 """
225 if not self._dirty or self._cache_dir is None:
226 return
227 self._cache_dir.mkdir(parents=True, exist_ok=True)
228 doc = {"version": self._CACHE_VERSION, "entries": self._serialize_entries()}
229 payload = _json.dumps(doc, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
230 cache_file = self._cache_dir / self._CACHE_FILENAME
231 fd, tmp_path = tempfile.mkstemp(
232 dir=self._cache_dir, prefix=self._TEMP_PREFIX, suffix=".tmp"
233 )
234 try:
235 with os.fdopen(fd, "wb") as fh:
236 fh.write(payload)
237 fh.flush()
238 os.fsync(fh.fileno())
239 except Exception:
240 try:
241 os.unlink(tmp_path)
242 except OSError:
243 pass
244 raise
245 try:
246 os.replace(tmp_path, cache_file)
247 self._dirty = False
248 logger.debug(
249 "✅ %s saved (%d entries)",
250 self.__class__.__name__,
251 len(self._entries),
252 )
253 except OSError as exc:
254 logger.warning("⚠️ %s save failed: %s", self.__class__.__name__, exc)
File History 1 commit
sha256:5eff0848db1d0748f3a195ca871a9275e7455d447690411ec2d83434e11baa65 feat(#216): persist write-once BranchMeta creation provenance Human minor 2 days ago