File size: 12,987 Bytes
526cf2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 | """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")
# A cache key must be a single flat path component. Production keys are always
# cache_key() output (64-hex), which matches; this is a defense-in-depth boundary
# check so a malformed/untrusted key can never traverse out of its tier dir (e.g.
# "../eval/x") and reach the eval tier through the dev-only put().
_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)
# Aliased tier dirs (same path, or symlinks resolving to one) would let the
# dev-only put() land in eval β collapsing the trust boundary. Reject it.
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" # the commit marker
def _blob_path(self, key: str, tier: str, byte_hash: str) -> Path:
return self._dir(tier) / f"{key}.{byte_hash}.png" # immutable, content-addressed
def has(self, key: str, tier: str) -> bool:
_validate_key(key)
return self._manifest_path(key, tier).exists() # manifest = commit marker
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)
# Content-addressed: a blob already at this name is reused ONLY if its bytes
# actually hash to that name. A corrupt blob squatting on the name (external
# tampering / fs corruption) is rewritten with the good bytes we hold β never
# trusted from the filename alone.
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) # durable contents before any manifest points at it
tmp_blob.replace(blob_path)
finally:
tmp_blob.unlink(missing_ok=True) # only bites if we raised before replace
_fsync(d) # durable blob rename
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) # the single atomic commit point
finally:
tmp_man.unlink(missing_ok=True)
_fsync(d) # durable manifest commit
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 # no commit marker = clean miss (orphan blob self-heals)
try:
manifest = json.loads(man_path.read_text())
except (ValueError, OSError) as exc: # corrupt/truncated manifest JSON
# Inside an except -> exc_info=True captures the REAL cause (JSON/OS error).
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():
# A DETECTED condition (no exception is being handled) -> structured ERROR,
# NO exc_info (there is no live traceback; exc_info=True would log "NoneType").
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") # revalidate under the held lock
if existing is not None:
new_hash = _byte_hash(_png_bytes(image)) # bytes _publish would write
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 # identical bytes: frozen entry stands β never rewrite its manifest
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()
|