| """Two-tier background cache — disk only (no HTTP, no provider knowledge). |
| |
| dev tier = every network fill lands here (non-authoritative, re-fillable). |
| eval tier = written ONLY by promote() (Task 8); replay/eval reads only here. |
| |
| Storage is CONTENT-ADDRESSED with a single atomic commit point: |
| * the image is an IMMUTABLE blob <key>.<byte_hash>.png — same bytes -> same |
| name, so it is written once and NEVER overwritten in place; |
| * the manifest <key>.json is the commit marker and REFERENCES that blob by its |
| byte_hash. Publishing writes the blob first (durable), then atomically renames |
| the manifest into place — that ONE manifest rename is the whole commit. |
| |
| So a reader (which takes NO lock) always sees either the complete old entry or the |
| complete new entry — never a torn pair, even during an overwrite: a new publish |
| writes a NEW blob alongside the old one and flips the manifest atomically, and an |
| interrupted publish just leaves an orphan blob the still-old manifest never points |
| at (harmless — re-fillable in dev, and the frozen eval tier never overwrites). |
| has()/get() key off the manifest marker. Public put() is the DEV door only; the |
| eval trust boundary is crossed solely by promote(). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import contextlib |
| import fcntl |
| import hashlib |
| import io |
| import json |
| import logging |
| import os |
| import re |
| import uuid |
| from pathlib import Path |
|
|
| from PIL import Image |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class CacheError(RuntimeError): |
| """A cache integrity / policy violation (missing blob, freeze, identity, tier, key).""" |
|
|
|
|
| _TIERS = ("dev", "eval") |
|
|
| |
| |
| |
| |
| _KEY_RE = re.compile(r"\A[A-Za-z0-9_-]+\Z") |
|
|
|
|
| def _validate_key(key: str) -> str: |
| if not isinstance(key, str) or not _KEY_RE.match(key): |
| raise CacheError(f"invalid cache key {key!r}: must match [A-Za-z0-9_-]+") |
| return key |
|
|
|
|
| def cache_key(fields: dict) -> str: |
| """Stable SHA-256 over a canonicalized dict (sorted keys, compact).""" |
| blob = json.dumps(fields, sort_keys=True, separators=(",", ":")) |
| return hashlib.sha256(blob.encode("utf-8")).hexdigest() |
|
|
|
|
| def _byte_hash(data: bytes) -> str: |
| return hashlib.sha256(data).hexdigest() |
|
|
|
|
| def _fsync(path: Path) -> None: |
| """fsync a file or directory so a rename/write survives a crash (POSIX/Linux).""" |
| fd = os.open(path, os.O_RDONLY) |
| try: |
| os.fsync(fd) |
| finally: |
| os.close(fd) |
|
|
|
|
| def _png_bytes(image: Image.Image) -> bytes: |
| buf = io.BytesIO() |
| image.convert("RGB").save(buf, format="PNG") |
| return buf.getvalue() |
|
|
|
|
| class Cache: |
| def __init__(self, dev_dir, eval_dir, *, replay: bool = False): |
| self.dev_dir = Path(dev_dir) |
| self.eval_dir = Path(eval_dir) |
| self.replay = replay |
| self.dev_dir.mkdir(parents=True, exist_ok=True) |
| self.eval_dir.mkdir(parents=True, exist_ok=True) |
| |
| |
| if self.dev_dir.resolve() == self.eval_dir.resolve(): |
| raise CacheError( |
| "dev and eval cache dirs must be distinct — " |
| "aliasing them defeats the eval trust boundary" |
| ) |
|
|
| def _dir(self, tier: str) -> Path: |
| if tier not in _TIERS: |
| raise CacheError(f"unknown cache tier {tier!r} (use 'dev' or 'eval')") |
| return self.eval_dir if tier == "eval" else self.dev_dir |
|
|
| def _manifest_path(self, key: str, tier: str) -> Path: |
| return self._dir(tier) / f"{key}.json" |
|
|
| def _blob_path(self, key: str, tier: str, byte_hash: str) -> Path: |
| return self._dir(tier) / f"{key}.{byte_hash}.png" |
|
|
| def has(self, key: str, tier: str) -> bool: |
| _validate_key(key) |
| return self._manifest_path(key, tier).exists() |
|
|
| def put(self, key: str, image: Image.Image, manifest: dict) -> None: |
| """Publish an entry to the DEV tier. Eval is written only by promote().""" |
| _validate_key(key) |
| self._publish(key, "dev", image, manifest) |
|
|
| def _publish(self, key: str, tier: str, image: Image.Image, manifest: dict) -> None: |
| """Publish image+manifest to `tier` with a single atomic commit. |
| |
| 1. Materialize the image as an immutable content-addressed blob (idempotent: |
| if the blob already exists its bytes are identical, so skip the write). |
| 2. Atomically rename the manifest (which references the blob by byte_hash) |
| into place — this ONE rename commits the entry. A crash between the two |
| steps leaves only an orphan blob, never a torn entry, and an overwrite |
| adds a new blob rather than mutating the one the old manifest points at. |
| |
| Temp names use a uuid4 so uncoordinated writers (even two Cache instances in |
| one process) never collide, and each temp lifecycle is wrapped in try/finally |
| so a mid-write failure leaves no stray `.tmp` behind. Blob and manifest are |
| fsync'd (contents + directory) so a committed manifest is never durable ahead |
| of the bytes it references. |
| """ |
| d = self._dir(tier) |
| data = _png_bytes(image) |
| byte_hash = _byte_hash(data) |
| manifest = {**manifest, "byte_hash": byte_hash} |
| blob_path = self._blob_path(key, tier, byte_hash) |
|
|
| |
| |
| |
| |
| needs_blob = True |
| if blob_path.exists(): |
| needs_blob = _byte_hash(blob_path.read_bytes()) != byte_hash |
| if needs_blob: |
| tmp_blob = d / f"{blob_path.name}.{uuid.uuid4().hex}.tmp" |
| try: |
| tmp_blob.write_bytes(data) |
| _fsync(tmp_blob) |
| tmp_blob.replace(blob_path) |
| finally: |
| tmp_blob.unlink(missing_ok=True) |
| _fsync(d) |
|
|
| man_path = self._manifest_path(key, tier) |
| tmp_man = d / f"{key}.json.{uuid.uuid4().hex}.tmp" |
| try: |
| tmp_man.write_text(json.dumps(manifest, indent=2)) |
| _fsync(tmp_man) |
| tmp_man.replace(man_path) |
| finally: |
| tmp_man.unlink(missing_ok=True) |
| _fsync(d) |
| logger.debug("cache write", extra={"tier": tier, "key": key[:12]}) |
|
|
| def get( |
| self, key: str, tier: str, *, expect_slug: str | None = None |
| ) -> tuple[Image.Image, dict] | None: |
| """Return (image, manifest) or None on a clean miss (no commit marker). |
| |
| When the manifest marker IS present, hard-errors (CacheError) if it |
| references a missing image blob (corruption), if the blob's bytes do not |
| hash to the recorded byte_hash (tampering), or on a config-identity |
| mismatch (expect_slug). |
| """ |
| _validate_key(key) |
| man_path = self._manifest_path(key, tier) |
| if not man_path.exists(): |
| logger.debug("cache miss", extra={"tier": tier, "key": key[:12]}) |
| return None |
| try: |
| manifest = json.loads(man_path.read_text()) |
| except (ValueError, OSError) as exc: |
| |
| logger.error("cache guard: unreadable manifest", |
| extra={"tier": tier, "key": key[:12]}, exc_info=True) |
| raise CacheError(f"unreadable manifest for {key} in {tier}") from exc |
| byte_hash = manifest.get("byte_hash") |
| blob_path = self._blob_path(key, tier, byte_hash or "") |
| if not byte_hash or not blob_path.exists(): |
| |
| |
| logger.error("cache guard: missing image blob", |
| extra={"tier": tier, "key": key[:12]}) |
| raise CacheError( |
| f"manifest for {key} in {tier} references a missing image blob" |
| ) |
| data = blob_path.read_bytes() |
| if _byte_hash(data) != byte_hash: |
| logger.error("cache guard: byte-hash mismatch", |
| extra={"tier": tier, "key": key[:12]}) |
| raise CacheError(f"byte-hash mismatch for {key} in {tier}") |
| if expect_slug is not None and manifest.get("model_slug") != expect_slug: |
| logger.error("cache guard: config-identity mismatch", |
| extra={"tier": tier, "key": key[:12], |
| "have": manifest.get("model_slug"), "want": expect_slug}) |
| raise CacheError( |
| f"config-identity: entry slug {manifest.get('model_slug')!r} " |
| f"!= configured {expect_slug!r}" |
| ) |
| image = Image.open(io.BytesIO(data)).convert("RGB") |
| logger.debug("cache hit", extra={"tier": tier, "key": key[:12]}) |
| return image, manifest |
|
|
| def promote(self, key: str, *, is_eligible) -> None: |
| """Freeze a dev entry into the eval tier — the ONLY eval-write path. |
| |
| The whole critical section (eligibility -> freeze check -> publish) runs |
| under the EVAL-tier lock(key, "eval"), and eval is re-read while the lock |
| is held (revalidate-while-locked). Locking the eval tier — not dev — is |
| what serializes competing promoters even when they were constructed with |
| different dev dirs but a shared eval dir, so two promoters can never both |
| pass the freeze and publish different bytes: exactly one distinct content |
| wins. The freeze compares the freshly re-encoded bytes (what _publish will |
| write) against the frozen eval hash, so a lossless decode/re-encode never |
| false-trips the freeze; a matching hash is an idempotent no-op that does |
| NOT rewrite the already-frozen eval manifest. |
| |
| Precondition: the dev entry is expected to be already committed. promote |
| holds only the eval lock, so it does NOT wait for an in-flight dev fill of |
| the same key (callers fill-then-promote sequentially). If dev is absent it |
| raises rather than blocking — a safe failure, never corruption. |
| """ |
| _validate_key(key) |
| with self.lock(key, "eval"): |
| entry = self.get(key, "dev") |
| if entry is None: |
| raise CacheError(f"cannot promote {key}: not in dev cache") |
| image, manifest = entry |
| slug = manifest.get("model_slug") |
| if not is_eligible(slug): |
| raise CacheError(f"cannot promote {key}: model {slug!r} not promotion-eligible") |
|
|
| existing = self.get(key, "eval") |
| if existing is not None: |
| new_hash = _byte_hash(_png_bytes(image)) |
| if existing[1].get("byte_hash") != new_hash: |
| raise CacheError(f"eval freeze: {key} differs from the frozen eval entry") |
| logger.debug("promote no-op: already frozen", extra={"key": key[:12]}) |
| return |
| self._publish(key, "eval", image, manifest) |
| logger.info("promoted to eval", extra={"key": key[:12], "model_slug": slug}) |
|
|
| @contextlib.contextmanager |
| def lock(self, key: str, tier: str = "dev"): |
| """Exclusive per-key lock (fcntl.flock) scoped to a tier's directory. |
| |
| Cross-process mutual exclusion so parallel env workers don't make duplicate |
| paid calls or race on the same key. The lock lives in the directory of the |
| tier it guards — dev fills lock `dev` (default), promote locks `eval` — so |
| instances sharing that tier's dir serialize even if their other dir differs. |
| POSIX/Linux. |
| """ |
| _validate_key(key) |
| lock_path = self._dir(tier) / f"{key}.lock" |
| handle = open(lock_path, "w") |
| try: |
| fcntl.flock(handle, fcntl.LOCK_EX) |
| yield |
| finally: |
| fcntl.flock(handle, fcntl.LOCK_UN) |
| handle.close() |
|
|