Source code for app.storage.snapshots

"""Sealed-snapshot writer.

Workers write live progress to `sparse/`; periodically they "seal" the
current state into `snapshots/{seq}/` via atomic dir rename so the API
can read without racing the writer.

Sealing protocol:
  1. tmp = `snapshots/.tmp_{seq}`
  2. copy live `sparse/` (and any sidecar JSONs) into tmp
  3. write `tmp/.complete` last
  4. `os.replace(tmp, target)` (atomic on the same FS)
  5. update `latest` pointer (text file holding seq number)

API readers:
  - list seq dirs (filter to those with `.complete` marker)
  - read inside any sealed dir; data is immutable
"""

from __future__ import annotations

import contextlib
import json
import os
import shutil
from pathlib import Path


[docs] class SnapshotStore: def __init__(self, root: Path) -> None: self.root = root self.snapshots = root / "snapshots" self.snapshots.mkdir(parents=True, exist_ok=True)
[docs] def seal(self, *, seq: int, source_dir: Path, summary: dict | None = None) -> Path: target = self.snapshots / f"{seq:08d}" if target.exists(): raise FileExistsError(f"Snapshot seq {seq} already sealed") tmp = self.snapshots / f".tmp_{seq:08d}" if tmp.exists(): shutil.rmtree(tmp) if source_dir.is_dir(): shutil.copytree(source_dir, tmp) else: tmp.mkdir(parents=True) if summary is not None: (tmp / "summary.json").write_text( json.dumps(summary, sort_keys=True, indent=2), encoding="utf-8" ) # Marker last so partial dirs are easy to detect. (tmp / ".complete").write_text(str(seq), encoding="utf-8") os.replace(tmp, target) self._set_latest(seq) return target
[docs] def list_sealed(self) -> list[int]: out: list[int] = [] if not self.snapshots.exists(): return out for child in self.snapshots.iterdir(): if ( child.is_dir() and not child.name.startswith(".tmp_") and (child / ".complete").is_file() ): try: out.append(int(child.name)) except ValueError: continue out.sort() return out
[docs] def latest(self) -> int | None: latest_file = self.snapshots / "latest" if not latest_file.is_file(): seqs = self.list_sealed() return seqs[-1] if seqs else None try: return int(latest_file.read_text().strip()) except (ValueError, OSError): return None
[docs] def path_for(self, seq: int) -> Path: return self.snapshots / f"{seq:08d}"
[docs] def gc(self, *, keep_last: int = 3) -> list[int]: seqs = self.list_sealed() if len(seqs) <= keep_last: return [] to_drop = seqs[:-keep_last] for s in to_drop: with contextlib.suppress(OSError): shutil.rmtree(self.path_for(s)) return to_drop
def _set_latest(self, seq: int) -> None: # Plain text file is portable across SQLite/Postgres-shaped reads # and across Windows/Linux. Write+rename for atomicity. latest = self.snapshots / "latest" tmp = self.snapshots / ".latest.tmp" tmp.write_text(str(seq), encoding="utf-8") os.replace(tmp, latest)