cache_base.py
python
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3
docs: revert migrate hub-scoping/domain-integers rows from …
Sonnet 5
17 hours 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:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3
docs: revert migrate hub-scoping/domain-integers rows from …
Sonnet 5
17 hours ago