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:
ykcheungab
2026-10-04 16:53:50 +08:00
parent 1aa454311f
commit a92a2ba237
14 changed files with 1138 additions and 170 deletions
@@ -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 —
+167 -9
View File
@@ -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))
+37
View File
@@ -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"
+141
View File
@@ -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."""