"""AZ-701 — per-job temp-file lifecycle. One ``StorageRoot`` rooted at ``REPLAY_API_STORAGE_ROOT``. Each job allocates a subdirectory ``//`` containing the uploaded ``tlog`` + ``video`` + ``calibration`` plus the estimator's outputs (``emissions.jsonl``, the AZ-699 report, the AZ-700 map). The directory is deleted on job completion (``release_job``) and on service shutdown (``cleanup_all``). The service deliberately does NOT keep finished-job artefacts forever — invariant 2 in the contract. """ from __future__ import annotations import logging import shutil from dataclasses import dataclass from pathlib import Path __all__ = ["JobStorage", "StorageRoot"] _LOGGER = logging.getLogger("gps_denied_onboard.replay_api.storage") @dataclass(frozen=True, slots=True) class JobStorage: """The per-job paths the handler hands to the runner. Both ``tlog_path`` and ``csv_path`` are reserved on disk; the handler writes to exactly one and leaves the other unused. The ``ReplayInputs`` DTO carries ``None`` for the branch that wasn't written so downstream consumers know which clock source applies. """ root: Path tlog_path: Path csv_path: Path video_path: Path calibration_path: Path output_dir: Path class StorageRoot: """Parent of per-job storage directories. The class is intentionally thin — the registry calls ``allocate_job`` at submit-time and ``release_job`` at terminal transitions; nothing else owns mutation rights. """ def __init__(self, root: Path) -> None: self._root = root self._root.mkdir(parents=True, exist_ok=True) @property def root(self) -> Path: return self._root def allocate_job(self, job_id: str) -> JobStorage: job_root = self._root / job_id job_root.mkdir(parents=True, exist_ok=False) output_dir = job_root / "output" output_dir.mkdir(parents=True, exist_ok=True) return JobStorage( root=job_root, tlog_path=job_root / "input.tlog", csv_path=job_root / "input.csv", video_path=job_root / "input.mp4", calibration_path=job_root / "calibration.json", output_dir=output_dir, ) def release_job(self, job_id: str) -> None: target = self._root / job_id if not target.exists(): return try: shutil.rmtree(target) except OSError as exc: _LOGGER.warning( "failed to delete per-job storage %s: %s", target, exc ) def cleanup_all(self) -> None: for child in self._root.iterdir(): if child.is_dir(): try: shutil.rmtree(child) except OSError as exc: _LOGGER.warning( "failed to delete per-job storage %s: %s", child, exc, )