Buckets:
| """Per-stage on-disk artifact cache: fingerprint + skip-if-done + resume. | |
| Follows the repo's existing ``meta.json`` sidecar convention (see | |
| ``scripts/run_object_pipeline.py``'s per-stage ``meta.json`` and ``outputs/*/meta.json``'s | |
| ``limitations`` block) rather than inventing a new one: a stage directory holds | |
| whatever artifacts it wants plus one ``meta.json`` recording the fingerprint of the | |
| inputs that produced them, an arbitrary small ``payload`` (paths/stats worth | |
| keeping without re-reading the artifacts), and a ``limitations`` list carried | |
| straight through to the final export's own meta.json. | |
| The one rule that matters more than the format: **a half-written artifact that | |
| looks fresh is worse than no cache at all.** A resumed run that trusts a | |
| crashed-mid-write ``meta.json`` will build every later stage on top of garbage | |
| without ever knowing it. So every write goes through | |
| :func:`fpgm.utils.io.atomic_write` (temp file + ``os.replace``), and every read | |
| treats a missing *or* corrupt ``meta.json`` as "not fresh" rather than raising -- | |
| a crash must make the stage re-run, never silently reuse whatever bytes happened | |
| to land on disk. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| from fpgm.utils.io import atomic_write, ensure_dir | |
| from fpgm.utils.logging import get_logger | |
| logger = get_logger(__name__) | |
| _META_FILENAME = "meta.json" | |
| def _jsonable(value: Any) -> Any: | |
| """Recursively coerce ``value`` into something ``json.dumps`` accepts. | |
| Fingerprints are built out of whatever a caller had lying around -- numpy | |
| scalars from a config, ``Path`` objects, tuples -- and none of those survive | |
| a JSON round trip unchanged unless normalised first. Doing that normalisation | |
| once, here, on both the write and the compare side is what keeps | |
| :meth:`StageCache.is_fresh` from ever comparing "the same fingerprint" against | |
| itself and getting ``False`` back because a tuple turned into a list on one | |
| side but not the other. | |
| """ | |
| if isinstance(value, np.generic): | |
| return value.item() | |
| if isinstance(value, np.ndarray): | |
| return [_jsonable(v) for v in value.tolist()] | |
| if isinstance(value, Path): | |
| return str(value) | |
| if isinstance(value, dict): | |
| return {str(k): _jsonable(v) for k, v in value.items()} | |
| if isinstance(value, (list, tuple)): | |
| return [_jsonable(v) for v in value] | |
| return value | |
| class StageCache: | |
| """Artifact directory per stage, each with a fingerprinted ``meta.json``. | |
| Usage:: | |
| cache = StageCache(outputs_root / episode_uuid) | |
| fp = {"config_hash": ..., "input_mtime": ..., "code_version": "s3.v1"} | |
| if not cache.is_fresh("dense_depth", fp): | |
| payload = run_dense_depth_stage(...) | |
| cache.write_meta("dense_depth", fp, payload, limitations=[...]) | |
| meta = cache.read_meta("dense_depth") | |
| """ | |
| def __init__(self, root: Path): | |
| self.root = ensure_dir(Path(root)) | |
| def stage_dir(self, name: str) -> Path: | |
| """The directory a stage's artifacts (and its ``meta.json``) live in.""" | |
| return ensure_dir(self.root / name) | |
| def _meta_path(self, name: str) -> Path: | |
| return self.stage_dir(name) / _META_FILENAME | |
| def is_fresh(self, name: str, fingerprint: dict[str, Any]) -> bool: | |
| """``True`` iff stage ``name`` has a valid ``meta.json`` matching ``fingerprint``. | |
| A fingerprint should capture everything the stage's output depends on: | |
| input file identities (path + mtime or hash), the relevant config values, | |
| and a code-version tag for the stage's own logic -- ``StageCache`` treats | |
| the fingerprint as an opaque dict and does not try to guess what belongs | |
| in it. Any mismatch (including keys present on only one side) counts as | |
| stale, by ordinary dict equality after normalisation. | |
| """ | |
| meta = self.read_meta(name) | |
| if meta is None: | |
| return False | |
| return meta.get("fingerprint") == _jsonable(fingerprint) | |
| def read_meta(self, name: str) -> dict[str, Any] | None: | |
| """Parsed ``meta.json`` for stage ``name``, or ``None`` if missing/corrupt. | |
| Corruption (truncated JSON from a crash mid-write, before ``atomic_write`` | |
| existed or if it was bypassed, or simple disk damage) is deliberately not | |
| an exception here: the whole point of caching is that a caller can do | |
| ``if not cache.is_fresh(...): recompute()`` without a try/except around | |
| every call, and "not fresh" already means "recompute", which is exactly | |
| the right response to corruption too. | |
| """ | |
| path = self._meta_path(name) | |
| if not path.exists(): | |
| return None | |
| try: | |
| raw = path.read_text() | |
| except OSError as exc: | |
| logger.warning("stage %r: could not read %s: %s", name, path, exc) | |
| return None | |
| try: | |
| meta = json.loads(raw) | |
| except json.JSONDecodeError as exc: | |
| logger.warning( | |
| "stage %r: %s is corrupt (%s); treating as not-fresh, not raising", | |
| name, | |
| path, | |
| exc, | |
| ) | |
| return None | |
| if not isinstance(meta, dict): | |
| logger.warning("stage %r: %s did not contain a JSON object", name, path) | |
| return None | |
| return meta | |
| def write_meta( | |
| self, | |
| name: str, | |
| fingerprint: dict[str, Any], | |
| payload: dict[str, Any], | |
| limitations: list[str], | |
| ) -> Path: | |
| """Atomically write stage ``name``'s ``meta.json``. | |
| Writes through :func:`fpgm.utils.io.atomic_write`: the new content is | |
| written to a sibling temp file and ``os.replace``-d into place, so a | |
| process killed mid-write leaves either the previous ``meta.json`` | |
| (correctly still "fresh" for the previous fingerprint, or stale and | |
| correctly re-run) or nothing at all -- never a truncated file that | |
| :meth:`read_meta` would have to guess about. | |
| """ | |
| meta = { | |
| "fingerprint": _jsonable(fingerprint), | |
| "payload": _jsonable(payload), | |
| "limitations": [str(item) for item in limitations], | |
| } | |
| data = json.dumps(meta, indent=2, sort_keys=True).encode("utf-8") | |
| path = self._meta_path(name) | |
| atomic_write(path, data) | |
| return path | |
Xet Storage Details
- Size:
- 6.46 kB
- Xet hash:
- 90a2b827be9f0de0f68be90cacc6e035fc662cb6096e4bbd85b03acc680ae63d
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.