Read the analysis cache before refusing a pass, and own the cache root
Opening a recording while the session that recorded it was still live never reached ``stored_result()``, so the panel reported no result over a recording whose result was on disk. The guard now follows the cache read, results move to the app's derived-data root (swept at startup, the old in-recording root ignored), and the panel states which of the four no-result states it is in, with the pass's own reason. Verified: apps/studio 462/0/18, shared 431/0/1, autocal 358, ui_check 628/0, both gates clean, real-GL snapshots per state.
This commit is contained in:
@@ -68,6 +68,31 @@ RAW_NOTE_KEY = "panels.replay_raw_note"
|
||||
#: line says which field is absent instead of the view looking broken.
|
||||
MISSING_NOTE_KEY = "panels.frame_missing_note"
|
||||
|
||||
#: The four states of "this recording has no result to draw from", which one line under
|
||||
#: the canvas has to tell apart. They are facts about the *analysis*, not about the
|
||||
#: frame: the same recorded picture carries all four, and the operator's next action is
|
||||
#: different in each. A single "no result yet" line was true in only the first of them,
|
||||
#: and its promise ("opening a recording runs a pass") is false in the other three.
|
||||
RESULT_NONE = "none"
|
||||
#: A pass is in flight: the result is coming, so waiting is the action.
|
||||
RESULT_RUNNING = "running"
|
||||
#: A pass ended without a result, or was refused before it started. ``result_reason``
|
||||
#: carries why — never a bare "failed", because "the chain failed on frame 41" and "a
|
||||
#: session is running so no chain was opened" send the operator to different places.
|
||||
RESULT_FAILED = "failed"
|
||||
#: No pass can produce one for this frame at all: a bare video has no session metadata
|
||||
#: and no analysis source, so the only way to numbers is the recompute pass.
|
||||
RESULT_IMPOSSIBLE = "impossible"
|
||||
RESULT_STATES = (RESULT_NONE, RESULT_RUNNING, RESULT_FAILED, RESULT_IMPOSSIBLE)
|
||||
|
||||
#: The line each state shows. The first is the oldest and the only one whose wording
|
||||
#: may promise the automatic pass, because it is the only state in which one is coming.
|
||||
RESULT_NOTE_KEYS = {
|
||||
RESULT_RUNNING: "panels.replay_analysis_running",
|
||||
RESULT_FAILED: "panels.replay_analysis_failed",
|
||||
RESULT_IMPOSSIBLE: "panels.replay_analysis_unavailable",
|
||||
}
|
||||
|
||||
#: Sources whose frames carry derived fields.
|
||||
DERIVED_SOURCES = (SOURCE_LIVE, SOURCE_RECOMPUTE, SOURCE_ANALYSIS)
|
||||
|
||||
@@ -193,6 +218,13 @@ class ImageFrame:
|
||||
#: that needs derived data is served from the result. ``False`` means no result — the
|
||||
#: only case in which "a recording stores only the raw camera image" is the truth.
|
||||
result_available: bool = False
|
||||
#: Which of the four no-result states the producer is in (see :data:`RESULT_STATES`),
|
||||
#: and why. Read only when there is no result to draw from; ``""`` means "the
|
||||
#: producer did not say", which is read as :data:`RESULT_NONE` — the state whose line
|
||||
#: names the pass that produces one, and the only wording that was ever true before
|
||||
#: the states were told apart.
|
||||
result_state: str = ""
|
||||
result_reason: str = ""
|
||||
|
||||
def has_flow(self) -> bool:
|
||||
if self.flow is None:
|
||||
@@ -224,6 +256,10 @@ class ImagePlan:
|
||||
#: :attr:`note_fields` is non-empty the line takes it as ``fields``.
|
||||
note_key: str = ""
|
||||
note_fields: tuple[str, ...] = ()
|
||||
#: The cause a ``note_key`` that asks for one is formatted with (``{reason}``). Kept
|
||||
#: apart from :attr:`note_fields`, which names the *fields* a view is missing: a
|
||||
#: failure's own words are not a field name and must not be looked up as one.
|
||||
note_reason: str = ""
|
||||
|
||||
def accepts(self, mode: str) -> bool:
|
||||
return str(mode) in self.available_modes
|
||||
@@ -266,7 +302,9 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
|
||||
|
||||
The raw-recording note is therefore an *absence* statement and appears only in the
|
||||
one case where it is true: no result, nothing to draw, and the panel says which
|
||||
action produces one.
|
||||
action produces one. **Which** absence it is comes from ``frame.result_state`` — a
|
||||
pass that is running, one that failed or was refused with a reason, and a frame no
|
||||
pass can serve at all are three different absences, and each has its own line.
|
||||
"""
|
||||
derived = str(frame.source or SOURCE_LIVE) in DERIVED_SOURCES
|
||||
missing: list[str] = []
|
||||
@@ -275,6 +313,9 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
|
||||
if not frame.has_depth():
|
||||
missing.append("depth")
|
||||
if not derived and not frame.result_available:
|
||||
state = str(frame.result_state or RESULT_NONE)
|
||||
note_key = RESULT_NOTE_KEYS.get(state, RAW_NOTE_KEY)
|
||||
reason = str(frame.result_reason or "")
|
||||
return ImagePlan(
|
||||
available_modes=(RECORDED,),
|
||||
default_mode=RECORDED,
|
||||
@@ -283,7 +324,10 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
|
||||
frame_id=int(frame.frame_id),
|
||||
t_rel_s=float(frame.t_rel_s),
|
||||
stale=bool(frame.stale),
|
||||
note_key=RAW_NOTE_KEY,
|
||||
note_key=note_key,
|
||||
# Only the failed/refused line takes the reason: a state that has none must
|
||||
# not render an empty placeholder into a sentence that names a cause.
|
||||
note_reason=reason if note_key == RESULT_NOTE_KEYS[RESULT_FAILED] else "",
|
||||
)
|
||||
if not derived:
|
||||
# A recording with a result waiting behind it. ``RECORDED`` stays the default —
|
||||
|
||||
@@ -18,6 +18,17 @@ properties matter more than speed:
|
||||
Storage is not free, so :func:`evict` removes whole results against a stated size cap.
|
||||
It only ever deletes a direct child of the cache root that carries a ``meta.json``, so
|
||||
a wrong path cannot turn into a recursive delete of something else.
|
||||
|
||||
**The root is the app's own derived-data directory, never the recording's folder.**
|
||||
Results used to be written to ``<recording>/analysis``, which put the app's own
|
||||
rebuildable data inside the operator's measurement — and, because the *root* is
|
||||
path-dependent while the *key* is not, made a result invisible as soon as the recording
|
||||
was opened from anywhere else (a copy, another machine, the same session on a different
|
||||
drive letter). ``paths.analysis_cache_dir()`` is the owner's decision: one root per user
|
||||
under ``%LOCALAPPDATA%\\OrisysStudio\\analysis-cache``, addressed by the result key
|
||||
alone. A result written under the old root is **ignored, not migrated and not deleted**:
|
||||
the app never writes into a recording folder again, and a leftover ``analysis``
|
||||
directory is left exactly as the operator's own tooling left it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,6 +47,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared import paths as paths_module
|
||||
from orisys_app_shared.analysis.model import (
|
||||
ANALYSIS_FORMAT,
|
||||
ANALYSIS_FORMAT_VERSION,
|
||||
@@ -59,14 +71,18 @@ from orisys_app_shared.analysis.model import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_INCOMPLETE_GRACE_S",
|
||||
"DEFAULT_MAX_BYTES",
|
||||
"INCOMPLETE_MARKER",
|
||||
"LEGACY_RESULT_DIRNAME",
|
||||
"META_NAME",
|
||||
"AnalysisWriter",
|
||||
"ResultCache",
|
||||
"ResultSummary",
|
||||
"SweepReport",
|
||||
"evict",
|
||||
"scan_results",
|
||||
"sweep",
|
||||
]
|
||||
|
||||
#: The file that declares a finished result. Its presence is not enough — the
|
||||
@@ -90,6 +106,18 @@ MAX_CHUNK_FRAMES = 4096
|
||||
#: holds roughly fourteen of them; the operator can lower it per call.
|
||||
DEFAULT_MAX_BYTES = 1 << 30
|
||||
|
||||
#: How long an unfinished result is left alone by :func:`sweep`. A directory is written
|
||||
#: only while a pass is running in *this* process, and the sweep runs at startup before
|
||||
#: any pass exists, but a second copy of the app (or a build script) can share the root,
|
||||
#: and deleting a directory another process is writing would turn its pass into a
|
||||
#: mysterious failure. An hour is far longer than any pass here and still removes the
|
||||
#: garbage of the previous session.
|
||||
DEFAULT_INCOMPLETE_GRACE_S = 3600.0
|
||||
|
||||
#: Where a result used to live: ``<recording>/analysis``. Named so the old location can
|
||||
#: be *recognised* (and reported), never so it can be written to again.
|
||||
LEGACY_RESULT_DIRNAME = RESULT_DIRNAME
|
||||
|
||||
#: Above this many remembered grid digests the writer stops deduplicating and gives a
|
||||
#: deviating frame its own keyframe. Correct either way; the map exists to keep the
|
||||
#: common case (one grid for the whole recording) at one keyframe.
|
||||
@@ -242,6 +270,101 @@ def evict(
|
||||
return removed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SweepReport:
|
||||
"""What one :func:`sweep` did: the garbage it removed, and what is left."""
|
||||
|
||||
removed: list[Path]
|
||||
#: The subset of :attr:`removed` that was unreadable by construction (an incomplete
|
||||
#: pass). Reported apart because removing these is never news, while removing a
|
||||
#: *complete* result is the size cap doing its job and worth seeing.
|
||||
incomplete: list[Path]
|
||||
freed_bytes: int
|
||||
remaining_bytes: int
|
||||
|
||||
@property
|
||||
def kept(self) -> bool:
|
||||
return not self.removed
|
||||
|
||||
|
||||
def sweep(
|
||||
root: str | Path,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
keep: Iterable[str | Path] = (),
|
||||
incomplete_grace_s: float = DEFAULT_INCOMPLETE_GRACE_S,
|
||||
now: float | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> SweepReport:
|
||||
"""Tidy one cache root: unfinished results first, then the size cap.
|
||||
|
||||
Two passes, in this order, because they answer different questions:
|
||||
|
||||
1. **Garbage.** A result that is not complete is refused by every default reader
|
||||
(Part A §7), so nothing can ever use it again; it is removed once it is older than
|
||||
``incomplete_grace_s``. A cancelled pass is *inspectable*, which is why the grace
|
||||
period exists rather than "delete on sight": the evidence of how far a pass got is
|
||||
worth keeping for a while.
|
||||
2. **Size.** :func:`evict` then removes whole results, incomplete first and oldest
|
||||
first, until the total fits ``max_bytes`` — never touching anything in ``keep``,
|
||||
and only ever a direct child of ``root`` carrying a ``meta.json``.
|
||||
|
||||
Returns what it removed and what is left, so a caller can log a real number instead
|
||||
of "swept". Never raises for a root that does not exist: the first run of an app has
|
||||
no cache, and that is not an error.
|
||||
"""
|
||||
base = Path(root)
|
||||
protected = {Path(item).resolve() for item in keep}
|
||||
if not base.is_dir():
|
||||
return SweepReport(removed=[], incomplete=[], freed_bytes=0, remaining_bytes=0)
|
||||
moment = time.time() if now is None else float(now)
|
||||
grace = max(0.0, float(incomplete_grace_s))
|
||||
start_bytes = sum(item.bytes_used for item in scan_results(base))
|
||||
removed: list[Path] = []
|
||||
incomplete: list[Path] = []
|
||||
for item in scan_results(base):
|
||||
if item.complete:
|
||||
continue
|
||||
if item.directory.resolve() in protected:
|
||||
continue
|
||||
age = moment - float(item.modified or 0.0)
|
||||
if item.modified and age < grace:
|
||||
continue
|
||||
if not (item.directory / META_NAME).is_file():
|
||||
# Same rule as ``evict``: only a result directory is a candidate, so a wrong
|
||||
# root cannot turn into a recursive delete of something else.
|
||||
continue
|
||||
if item.directory.parent.resolve() != base.resolve():
|
||||
continue
|
||||
removed.append(item.directory)
|
||||
incomplete.append(item.directory)
|
||||
if not dry_run:
|
||||
shutil.rmtree(item.directory, ignore_errors=False)
|
||||
evicted = evict(base, max_bytes=max_bytes, keep=keep, dry_run=dry_run)
|
||||
for path in evicted:
|
||||
if path not in removed:
|
||||
removed.append(path)
|
||||
if removed:
|
||||
logger.info(
|
||||
"analysis cache sweep: %s %d result(s) (%d incomplete)",
|
||||
"would remove" if dry_run else "removed",
|
||||
len(removed),
|
||||
len(incomplete),
|
||||
)
|
||||
# Measured after the fact rather than subtracted: ``evict`` decides what fits, and a
|
||||
# second measurement is the only honest answer to "what is left" (a dry run leaves
|
||||
# everything, which is what its number says).
|
||||
remaining = (
|
||||
start_bytes if dry_run else sum(item.bytes_used for item in scan_results(base))
|
||||
)
|
||||
return SweepReport(
|
||||
removed=removed,
|
||||
incomplete=incomplete,
|
||||
freed_bytes=max(0, start_bytes - remaining),
|
||||
remaining_bytes=remaining,
|
||||
)
|
||||
|
||||
|
||||
class AnalysisWriter:
|
||||
"""Append-only writer for one result directory.
|
||||
|
||||
@@ -570,22 +693,36 @@ class AnalysisWriter:
|
||||
|
||||
|
||||
class ResultCache:
|
||||
"""The results belonging to one recording, addressed by key.
|
||||
"""The stored results, addressed by key under the app's own derived-data root.
|
||||
|
||||
``root`` is usually ``<recording>/analysis``: a result then travels with the
|
||||
recording it describes, and deleting the recording deletes its cache. Nothing here
|
||||
writes to the current working directory — that matters because opening a *box*
|
||||
``Sensor`` drops a ~2 MB ``config/compound_eye.png`` relative to it (see
|
||||
``service.py``).
|
||||
``root`` defaults to :func:`~orisys_app_shared.paths.analysis_cache_dir` —
|
||||
``%LOCALAPPDATA%\\OrisysStudio\\analysis-cache`` — because a result is the app's
|
||||
rebuildable data and must not be written into the operator's recording folder. The
|
||||
*key* is what identifies a result (it ignores the recording's path), so the root is
|
||||
shared by every recording and a copied recording finds the same result.
|
||||
|
||||
A directory left under the old ``<recording>/analysis`` root is not read and not
|
||||
touched: see the module docstring.
|
||||
"""
|
||||
|
||||
def __init__(self, root: str | Path, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
|
||||
self.root = Path(root)
|
||||
def __init__(self, root: str | Path | None = None, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
|
||||
# Resolved at construction through the module, so a caller (or a test) can point
|
||||
# the app's derived-data root elsewhere without patching this class.
|
||||
self.root = (
|
||||
Path(paths_module.analysis_cache_dir()) if root is None else Path(root)
|
||||
)
|
||||
self.max_bytes = int(max_bytes)
|
||||
|
||||
@classmethod
|
||||
def for_recording(cls, recording_dir: str | Path, **kwargs: Any) -> ResultCache:
|
||||
return cls(Path(recording_dir) / RESULT_DIRNAME, **kwargs)
|
||||
"""The cache a recording's results live in.
|
||||
|
||||
The recording is named because that is how every caller thinks about it, and
|
||||
because the *key* — which is what actually identifies the result — is built from
|
||||
the recording's own metadata. It no longer decides the root.
|
||||
"""
|
||||
del recording_dir # the identity is in the key, not in the folder layout
|
||||
return cls(**kwargs)
|
||||
|
||||
def directory(self, key: ResultKey) -> Path:
|
||||
return self.root / key.digest
|
||||
@@ -621,5 +758,26 @@ class ResultCache:
|
||||
) -> list[Path]:
|
||||
return evict(self.root, max_bytes=self.max_bytes, keep=keep, dry_run=dry_run)
|
||||
|
||||
def sweep(
|
||||
self,
|
||||
*,
|
||||
keep: Iterable[str | Path] = (),
|
||||
incomplete_grace_s: float = DEFAULT_INCOMPLETE_GRACE_S,
|
||||
now: float | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> SweepReport:
|
||||
"""Tidy this root: unfinished results first, then the size cap.
|
||||
|
||||
The startup call site for the app's own cache; see :func:`sweep`.
|
||||
"""
|
||||
return sweep(
|
||||
self.root,
|
||||
max_bytes=self.max_bytes,
|
||||
keep=keep,
|
||||
incomplete_grace_s=incomplete_grace_s,
|
||||
now=now,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
def total_bytes(self) -> int:
|
||||
return sum(item.bytes_used for item in scan_results(self.root))
|
||||
|
||||
@@ -121,6 +121,43 @@ def recordings_dir() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
#: Directory name of the product's per-user data, under the OS's own root for it.
|
||||
DERIVED_DIR_NAME = "OrisysStudio"
|
||||
#: Subdirectory holding the offline analysis results.
|
||||
ANALYSIS_CACHE_DIRNAME = "analysis-cache"
|
||||
|
||||
|
||||
def derived_data_dir() -> Path:
|
||||
"""The OS-correct per-user root for **derived** (rebuildable) data.
|
||||
|
||||
Windows: ``%LOCALAPPDATA%\\OrisysStudio`` (falling back to
|
||||
``%USERPROFILE%\\AppData\\Local`` when the variable is unset — a service or a
|
||||
stripped environment, where the fallback is still the right directory). Everywhere
|
||||
else: ``$XDG_CACHE_HOME/OrisysStudio``, or ``~/.cache/OrisysStudio``.
|
||||
|
||||
Deliberately *not* :func:`outputs_dir`: in a packaged build that is the EXE's own
|
||||
directory, and it holds the recordings. Anything the app can rebuild belongs in the
|
||||
user's own derived-data area, where a reinstall cannot destroy it and where writing
|
||||
it needs no permission on the installation.
|
||||
"""
|
||||
name = DERIVED_DIR_NAME
|
||||
if os.name == "nt":
|
||||
local = os.environ.get("LOCALAPPDATA", "").strip()
|
||||
base = (
|
||||
Path(local)
|
||||
if local
|
||||
else Path(os.environ.get("USERPROFILE", str(Path.home()))) / "AppData" / "Local"
|
||||
)
|
||||
return base / name
|
||||
cache = os.environ.get("XDG_CACHE_HOME", "").strip()
|
||||
return (Path(cache) if cache else Path.home() / ".cache") / name
|
||||
|
||||
|
||||
def analysis_cache_dir() -> Path:
|
||||
"""Where the offline analysis results live: the app's own root, never a recording."""
|
||||
return derived_data_dir() / ANALYSIS_CACHE_DIRNAME
|
||||
|
||||
|
||||
def preference_path() -> Path:
|
||||
return app_root() / "preference.json"
|
||||
|
||||
|
||||
@@ -32,11 +32,14 @@ import numpy as np
|
||||
import pytest
|
||||
from orisys_app_shared.analysis import model as analysis_model
|
||||
from orisys_app_shared.analysis.cache import (
|
||||
DEFAULT_INCOMPLETE_GRACE_S,
|
||||
INCOMPLETE_MARKER,
|
||||
LEGACY_RESULT_DIRNAME,
|
||||
META_NAME,
|
||||
ResultCache,
|
||||
evict,
|
||||
scan_results,
|
||||
sweep,
|
||||
)
|
||||
from orisys_app_shared.analysis.model import (
|
||||
FRAME_HEADER,
|
||||
@@ -624,6 +627,144 @@ def test_eviction_removes_whole_results_against_the_cap(tmp_path):
|
||||
assert kept.is_dir()
|
||||
|
||||
|
||||
# ------------------------------------------------- cache root and lifetime policy
|
||||
def _write_result(root: Path, name: str, *, complete: bool, modified: float, blob: int = 4096):
|
||||
"""One result directory as the writer leaves it, with a chosen age."""
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True)
|
||||
(directory / META_NAME).write_text(
|
||||
json.dumps({"complete": complete, "key": name, "format": "x"}), encoding="utf-8"
|
||||
)
|
||||
(directory / "blob.bin").write_bytes(b"\0" * blob)
|
||||
if not complete:
|
||||
(directory / INCOMPLETE_MARKER).write_text("{}", encoding="utf-8")
|
||||
for path in directory.rglob("*"):
|
||||
os.utime(path, (modified, modified))
|
||||
os.utime(directory, (modified, modified))
|
||||
return directory
|
||||
|
||||
|
||||
def test_the_cache_root_is_the_app_s_own_and_never_the_recording_s(tmp_path):
|
||||
"""Results go to the app's derived-data root; a recording folder is never written.
|
||||
|
||||
This is the owner's constraint made checkable: the recording a result describes is
|
||||
an input, and an input directory must come back byte-identical. It is also what makes
|
||||
the cache findable at all — the *key* is path-independent, so one root for every
|
||||
recording serves a copy exactly as it serves the original, while a root inside the
|
||||
recording only ever served the one folder it was written beside.
|
||||
"""
|
||||
from orisys_app_shared import paths as paths_module
|
||||
|
||||
assert paths_module.analysis_cache_dir() == (
|
||||
paths_module.derived_data_dir() / paths_module.ANALYSIS_CACHE_DIRNAME
|
||||
)
|
||||
assert paths_module.ANALYSIS_CACHE_DIRNAME in str(paths_module.analysis_cache_dir())
|
||||
# The recording's own folder is not the root, and ``for_recording`` does not put it
|
||||
# back: the recording is named for the key, not for the layout.
|
||||
recording = tmp_path / "recording"
|
||||
recording.mkdir()
|
||||
cache = ResultCache.for_recording(recording)
|
||||
assert cache.root != recording / LEGACY_RESULT_DIRNAME
|
||||
assert not str(cache.root).startswith(str(recording))
|
||||
assert cache.directory(result_key(
|
||||
recording="r", sensor_config="s", calibration="c"
|
||||
)).parent == cache.root
|
||||
# …and the root it defaults to is the OS one, resolved at construction so a caller
|
||||
# can point it elsewhere without patching this class.
|
||||
assert ResultCache().root == paths_module.analysis_cache_dir()
|
||||
|
||||
|
||||
def test_a_result_under_the_old_root_is_ignored_not_migrated(tmp_path):
|
||||
"""A leftover ``<recording>/analysis`` neither breaks an open nor gets touched.
|
||||
|
||||
The old root is a *recording the operator owns*, so the app may neither read it
|
||||
(the path is not the identity) nor delete it (it is not the app's data). A result
|
||||
for the same input under the app's own root is what is served, and the old directory
|
||||
is found exactly as it was left.
|
||||
"""
|
||||
from orisys_app_shared.analysis.model import result_key
|
||||
|
||||
recording = tmp_path / "recording"
|
||||
legacy = recording / LEGACY_RESULT_DIRNAME
|
||||
legacy.mkdir(parents=True)
|
||||
key = result_key(recording="r", sensor_config="s", calibration="c")
|
||||
(legacy / key.digest).mkdir()
|
||||
(legacy / key.digest / META_NAME).write_text(
|
||||
json.dumps({"complete": True, "key": key.digest}), encoding="utf-8"
|
||||
)
|
||||
before = sorted(str(path.relative_to(recording)) for path in recording.rglob("*"))
|
||||
|
||||
app_root = tmp_path / "app-cache"
|
||||
app_root.mkdir()
|
||||
cache = ResultCache(app_root)
|
||||
assert cache.exists(key) is False, "a result beside the recording answered for it"
|
||||
_write_result(app_root, key.digest, complete=True, modified=1_600_000_000)
|
||||
assert cache.exists(key) is True
|
||||
|
||||
# A sweep of the app root does not reach into the recording: the old result and the
|
||||
# recording's own files are all still there, untouched.
|
||||
assert sweep(app_root, max_bytes=0) is not None
|
||||
assert legacy.is_dir()
|
||||
assert sorted(
|
||||
str(path.relative_to(recording)) for path in recording.rglob("*")
|
||||
) == before
|
||||
|
||||
|
||||
def test_the_sweep_removes_unfinished_results_but_only_after_the_grace(tmp_path):
|
||||
"""Garbage first: an unreadable result goes once it is old, and not before.
|
||||
|
||||
An incomplete result can never be served (Part A §7), so it is the cheapest thing to
|
||||
lose — but it is also the only evidence of how far a cancelled pass got, which is why
|
||||
the age test exists rather than "delete on sight".
|
||||
"""
|
||||
root = tmp_path / "cache"
|
||||
root.mkdir()
|
||||
now = 1_700_000_000.0
|
||||
stale = _write_result(root, "stale", complete=False, modified=now - 7200)
|
||||
fresh = _write_result(root, "fresh", complete=False, modified=now - 30)
|
||||
finished = _write_result(root, "finished", complete=True, modified=now - 7200)
|
||||
|
||||
report = sweep(root, incomplete_grace_s=DEFAULT_INCOMPLETE_GRACE_S, now=now)
|
||||
assert report.removed == [stale], "the sweep took something it should not have"
|
||||
assert report.incomplete == [stale]
|
||||
assert stale.exists() is False
|
||||
assert fresh.is_dir(), "a pass that may still be running lost its directory"
|
||||
assert finished.is_dir(), "a readable result was removed while it fits the cap"
|
||||
assert report.freed_bytes > 0
|
||||
|
||||
|
||||
def test_the_sweep_protects_what_is_in_use_and_honours_the_cap(tmp_path):
|
||||
"""Then the cap: whole results, oldest first, and never the one being served."""
|
||||
root = tmp_path / "cache"
|
||||
root.mkdir()
|
||||
now = 1_700_000_000.0
|
||||
made = [
|
||||
_write_result(root, f"result-{index}", complete=True, modified=now - 100 + index)
|
||||
for index in range(3)
|
||||
]
|
||||
kept = made[2]
|
||||
size = sum(path.stat().st_size for path in kept.rglob("*") if path.is_file())
|
||||
# A cap that fits only the protected result: both others must go, and the protected
|
||||
# one must survive even when the arithmetic would prefer it (it is the newest).
|
||||
report = sweep(root, max_bytes=size, keep=[kept], now=now)
|
||||
assert kept.is_dir()
|
||||
assert made[0].exists() is False and made[1].exists() is False
|
||||
assert set(report.removed) == {made[0], made[1]}
|
||||
assert report.incomplete == []
|
||||
assert report.remaining_bytes <= size + 64
|
||||
|
||||
# A dry run removes nothing and reports what would go.
|
||||
again = _write_result(root, "result-3", complete=True, modified=now)
|
||||
plan = sweep(root, max_bytes=1, keep=[kept], now=now, dry_run=True)
|
||||
assert again.is_dir() and kept.is_dir()
|
||||
assert set(plan.removed) == {again}
|
||||
|
||||
# A root that does not exist is not an error: the first run of an app has no cache.
|
||||
missing = sweep(tmp_path / "absent", now=now)
|
||||
assert missing.removed == [] and missing.remaining_bytes == 0
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ incompleteness gate
|
||||
def test_a_result_without_metadata_is_never_served(tmp_path):
|
||||
"""The only thing that opens is a directory whose metadata says it finished."""
|
||||
|
||||
Reference in New Issue
Block a user