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
+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))