Source code for recon_gen.common.l2.cache

"""In-memory ``L2Instance`` cache + atomic file-write primitive (X.4.a.6).

Studio's source-of-truth contract (per ``SPEC_studio.md``): the L2 YAML on
disk is the hard truth; the in-memory ``L2Instance`` is a cache of the
file, never a parallel source. This module ships the two primitives that
contract rests on:

- ``L2InstanceCache`` — Studio-owned read cache. Loads once at startup
  via ``common/l2/loader.py::load_instance``; holds the parsed model so
  per-request YAML re-parse isn't on the hot path. Read-only here in
  X.4.a.6 (``get()``); the ``save()`` method that writes back to disk
  lands at X.4.d.3 once the editor's serializer (``serialize_l2``) is
  in place. ``replace(new_instance)`` is provided now so the X.4.d.1
  mutators have a typed seam to update the cache without touching disk.

- ``save_yaml_atomic(text, path)`` — atomic file write: temp file in
  the same directory, fsync the contents, then rename onto the target.
  POSIX rename(2) inside one filesystem is atomic, so a crash mid-write
  leaves either the old YAML or the new YAML on disk — never a torn
  half-written file. The temp lives in the target's parent dir
  specifically so the rename can't cross filesystems.

Severability: this module is Studio-side. Dashboards (``recon-gen
dashboards``) does NOT instantiate ``L2InstanceCache`` — it reads the L2
once at startup and keeps the parsed instance in its own scope. The
cache only exists when Studio mounts.
"""

from __future__ import annotations

import contextlib
import os
import tempfile
from pathlib import Path

from .loader import load_instance
from .primitives import L2Instance


[docs] def save_yaml_atomic(text: str, path: Path) -> None: """Atomically write ``text`` to ``path``. Writes to a temp file in ``path.parent`` (so the final rename stays within one filesystem and is therefore atomic per POSIX ``rename(2)``), fsyncs the file contents, then renames onto the target. A crash between fsync and rename leaves the old file intact; a crash during write leaves a stray ``.<name>.*.tmp`` that the next save overwrites. Caller's responsibility: ``path.parent`` must already exist. """ path = Path(path) fd, tmp_str = tempfile.mkstemp( prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), ) tmp = Path(tmp_str) try: with os.fdopen(fd, "w") as f: f.write(text) f.flush() os.fsync(f.fileno()) # Path.replace == os.rename on POSIX; atomic within one fs. tmp.replace(path) except BaseException: # Clean up the temp on any failure (write error, KeyboardInterrupt, # the rename itself raising). Suppress the cleanup error so the # original exception propagates. with contextlib.suppress(OSError): tmp.unlink() raise
[docs] class L2InstanceCache: """Studio-owned in-memory cache of one ``L2Instance``. Constructed once at Studio startup via ``L2InstanceCache.from_path``; bound to a single L2 YAML path for its lifetime. Studio request handlers call ``get()`` to read the current model; once X.4.d lands its mutators, they call ``replace(new_instance)`` to swap the cached value, and X.4.d.3's serializer-aware ``save()`` will pair ``replace`` with ``save_yaml_atomic`` so disk + cache stay in sync. Read-only-from-disk for now (X.4.a.6 ships ``get()`` + ``replace``); no reload-on-file-change watcher (per the SPEC's "Studio writes; nobody else writes" rule). """ __slots__ = ("_path", "_instance") def __init__(self, path: Path, instance: L2Instance) -> None: self._path = path self._instance = instance
[docs] @classmethod def from_path(cls, path: Path | str) -> L2InstanceCache: """Load the YAML at ``path`` and wrap the result in a cache.""" p = Path(path) return cls(p, load_instance(p))
@property def path(self) -> Path: return self._path
[docs] def get(self) -> L2Instance: return self._instance
[docs] def replace(self, new_instance: L2Instance) -> None: """Swap the cached instance without writing to disk. X.4.d's mutators (``mutate_l2`` / ``rename_identifier``) produce a new ``L2Instance`` from the old one; ``replace`` installs it. Pairing this with a disk write happens in ``save()`` below (which composes ``serialize_l2`` + ``save_yaml_atomic`` + ``replace``). """ self._instance = new_instance
[docs] def save(self, new_instance: L2Instance) -> None: """Persist + swap: serialize ``new_instance`` to YAML, atomically write it to ``self.path``, then ``replace`` the cached instance. The X.4.e PUT handler composes ``mutate_l2 → validate → save`` — every successful mutation lands on disk + in cache atomically (within one event loop tick). A crash mid-write leaves the prior YAML intact (per ``save_yaml_atomic``'s POSIX rename guarantee). Caller is responsible for validating ``new_instance`` BEFORE calling ``save`` — a structural-break delete that re-raises ``L2ValidationError`` should never reach disk. """ from .serializer import serialize_l2 save_yaml_atomic(serialize_l2(new_instance), self._path) self._instance = new_instance