fix(replay): serve admitted AI overlays without revalidation
This commit is contained in:
@@ -201,6 +201,19 @@ class RecordedPerceptionOverlayMux:
|
||||
recording_id=recording_id,
|
||||
)
|
||||
|
||||
def status(self, session_id: str, *, recording_id: str) -> dict[str, Any]:
|
||||
primary_status = getattr(self.primary, "status", None)
|
||||
if callable(primary_status):
|
||||
value = primary_status(session_id, recording_id=recording_id)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {
|
||||
"state": "idle",
|
||||
"phase": "idle",
|
||||
"elapsed_seconds": 0.0,
|
||||
"byte_length": None,
|
||||
}
|
||||
|
||||
|
||||
def validate_recorded_calibrated_fusion(
|
||||
job_root: Path,
|
||||
|
||||
@@ -11,7 +11,9 @@ import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, TypeGuard
|
||||
@@ -39,10 +41,13 @@ MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_LINE_BYTES = 4 * 1024 * 1024
|
||||
MAX_SOURCE_BYTES = 512 * 1024 * 1024
|
||||
OVERLAY_RENDERER_VERSION = "4"
|
||||
OVERLAY_ADMISSION_SCHEMA = "missioncore.e10-overlay-admission/v1"
|
||||
OVERLAY_CACHE_SCHEMA = "missioncore.e10-overlay-cache/v1"
|
||||
CUBOID_PRESENTATION_HOLD_NS = 500_000_000
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^e10-integrated-perception-[a-f0-9]{64}$")
|
||||
_SAFE_PACK_ID = re.compile(r"^e10-lidar-pack-[a-f0-9]{64}$")
|
||||
_SAFE_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
|
||||
_SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
@@ -67,6 +72,30 @@ class IntegratedPerceptionResult:
|
||||
report_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ResultDescriptor:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
result_json_sha256: str
|
||||
session_id: str
|
||||
job_id: str
|
||||
created_at_utc: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _OverlayAdmission:
|
||||
session_id: str
|
||||
result_id: str
|
||||
result_json_sha256: str
|
||||
result_created_at_utc: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FlightLock:
|
||||
lock: threading.Lock
|
||||
users: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PresentedCuboid:
|
||||
observed_ns: int
|
||||
@@ -329,7 +358,13 @@ def validate_integrated_perception_result(
|
||||
|
||||
|
||||
class IntegratedPerceptionOverlayStore:
|
||||
"""Prefer the newest accepted E10 result for a recorded session."""
|
||||
"""Serve the newest admitted E10 overlay without revalidating it on replay.
|
||||
|
||||
Result validation is an admission concern. Once a complete overlay has been
|
||||
rendered and sealed, its cache and admission sidecars are sufficient for
|
||||
the presentation path. A cache miss still fails closed and validates the
|
||||
exact selected result before rendering.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -345,9 +380,15 @@ class IntegratedPerceptionOverlayStore:
|
||||
self.lidar_packs_root = lidar_packs_root.expanduser().absolute()
|
||||
self.cache_root = cache_root.expanduser().absolute()
|
||||
self.ffmpeg_path = ffmpeg_path.expanduser().absolute()
|
||||
self._lock = threading.Lock()
|
||||
self._catalog_signature: tuple[Any, ...] | None = None
|
||||
self._catalog_lock = threading.Lock()
|
||||
self._descriptor_catalog_generation: tuple[int, int] | None = None
|
||||
self._descriptors_by_session: dict[str, tuple[_ResultDescriptor, ...]] = {}
|
||||
self._latest_by_session: dict[str, IntegratedPerceptionResult | None] = {}
|
||||
self._flight_guard = threading.Lock()
|
||||
self._flights: dict[tuple[str, str], _FlightLock] = {}
|
||||
self._materialization_lock = threading.Lock()
|
||||
self._status_lock = threading.Lock()
|
||||
self._status_by_recording: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
|
||||
def render(
|
||||
self,
|
||||
@@ -363,146 +404,357 @@ class IntegratedPerceptionOverlayStore:
|
||||
or _SAFE_RECORDING_ID.fullmatch(recording_id) is None
|
||||
):
|
||||
raise ValueError("integrated perception recording id is invalid")
|
||||
with self._lock:
|
||||
result = self._latest(session_id)
|
||||
if result is None:
|
||||
return None
|
||||
cache = _private_child(
|
||||
_private_child(_private_directory(self.cache_root), session_id),
|
||||
result.result_id,
|
||||
)
|
||||
output = cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
cached = _read_cache(output, sidecar, result, recording_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
payload = _render(
|
||||
result,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
ffmpeg_path=self.ffmpeg_path,
|
||||
temporary_root=cache,
|
||||
)
|
||||
temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, output)
|
||||
write_json_atomic(
|
||||
sidecar,
|
||||
{
|
||||
"schema_version": "missioncore.e10-overlay-cache/v1",
|
||||
"renderer_version": OVERLAY_RENDERER_VERSION,
|
||||
"result_id": result.result_id,
|
||||
"recording_id": recording_id,
|
||||
"byte_length": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return payload
|
||||
|
||||
def _latest(self, session_id: str) -> IntegratedPerceptionResult | None:
|
||||
signature = _directory_catalog_signature(
|
||||
self.jobs_root,
|
||||
self.results_root,
|
||||
self.lidar_packs_root,
|
||||
)
|
||||
if signature != self._catalog_signature:
|
||||
self._catalog_signature = signature
|
||||
self._latest_by_session.clear()
|
||||
if session_id in self._latest_by_session:
|
||||
return self._latest_by_session[session_id]
|
||||
key = (session_id, recording_id)
|
||||
self._set_status(key, state="preparing", phase="cache-lookup")
|
||||
try:
|
||||
jobs = sorted(self.jobs_root.iterdir())
|
||||
candidates = sorted(self.results_root.iterdir())
|
||||
except FileNotFoundError:
|
||||
self._latest_by_session[session_id] = None
|
||||
with self._single_flight(key):
|
||||
cached = self._read_admitted_cache(session_id, recording_id)
|
||||
if cached is not None:
|
||||
self._set_status(
|
||||
key,
|
||||
state="ready",
|
||||
phase="ready",
|
||||
byte_length=len(cached),
|
||||
)
|
||||
return cached
|
||||
self._set_status(key, state="preparing", phase="queued")
|
||||
with self._materialization_lock:
|
||||
self._set_status(key, state="preparing", phase="artifact-validation")
|
||||
result = self._latest(session_id)
|
||||
if result is None:
|
||||
self._set_status(key, state="unavailable", phase="unavailable")
|
||||
return None
|
||||
self._write_admission(result)
|
||||
cache = _private_child(
|
||||
_private_child(_private_directory(self.cache_root), session_id),
|
||||
result.result_id,
|
||||
)
|
||||
output = cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
cached = _read_cache(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=result.result_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if cached is not None:
|
||||
self._set_status(
|
||||
key,
|
||||
state="ready",
|
||||
phase="ready",
|
||||
byte_length=len(cached),
|
||||
)
|
||||
return cached
|
||||
self._set_status(key, state="preparing", phase="rendering")
|
||||
payload = _render(
|
||||
result,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
ffmpeg_path=self.ffmpeg_path,
|
||||
temporary_root=cache,
|
||||
)
|
||||
self._set_status(key, state="preparing", phase="cache-write")
|
||||
temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, output)
|
||||
write_json_atomic(
|
||||
sidecar,
|
||||
{
|
||||
"schema_version": OVERLAY_CACHE_SCHEMA,
|
||||
"renderer_version": OVERLAY_RENDERER_VERSION,
|
||||
"session_id": session_id,
|
||||
"result_id": result.result_id,
|
||||
"result_created_at_utc": result.created_at_utc,
|
||||
"recording_id": recording_id,
|
||||
"byte_length": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
self._set_status(
|
||||
key,
|
||||
state="ready",
|
||||
phase="ready",
|
||||
byte_length=len(payload),
|
||||
)
|
||||
return payload
|
||||
except BaseException:
|
||||
self._set_status(key, state="error", phase="error")
|
||||
raise
|
||||
|
||||
def status(self, session_id: str, *, recording_id: str) -> dict[str, Any]:
|
||||
if (
|
||||
_SAFE_RECORDING_ID.fullmatch(session_id) is None
|
||||
or _SAFE_RECORDING_ID.fullmatch(recording_id) is None
|
||||
):
|
||||
raise ValueError("integrated perception recording id is invalid")
|
||||
key = (session_id, recording_id)
|
||||
with self._status_lock:
|
||||
value = self._status_by_recording.get(key)
|
||||
if value is None:
|
||||
return {
|
||||
"state": "idle",
|
||||
"phase": "idle",
|
||||
"elapsed_seconds": 0.0,
|
||||
"byte_length": None,
|
||||
}
|
||||
elapsed = max(0.0, time.monotonic() - float(value["started_monotonic"]))
|
||||
return {
|
||||
"state": value["state"],
|
||||
"phase": value["phase"],
|
||||
"elapsed_seconds": round(elapsed, 3),
|
||||
"byte_length": value.get("byte_length"),
|
||||
}
|
||||
|
||||
def _set_status(
|
||||
self,
|
||||
key: tuple[str, str],
|
||||
*,
|
||||
state: str,
|
||||
phase: str,
|
||||
byte_length: int | None = None,
|
||||
) -> None:
|
||||
with self._status_lock:
|
||||
previous = self._status_by_recording.get(key)
|
||||
started = (
|
||||
float(previous["started_monotonic"])
|
||||
if previous is not None and previous.get("state") == "preparing"
|
||||
else time.monotonic()
|
||||
)
|
||||
self._status_by_recording[key] = {
|
||||
"state": state,
|
||||
"phase": phase,
|
||||
"started_monotonic": started,
|
||||
"byte_length": byte_length,
|
||||
}
|
||||
|
||||
@contextmanager
|
||||
def _single_flight(self, key: tuple[str, str]) -> Iterator[None]:
|
||||
with self._flight_guard:
|
||||
flight = self._flights.get(key)
|
||||
if flight is None:
|
||||
flight = _FlightLock(lock=threading.Lock())
|
||||
self._flights[key] = flight
|
||||
flight.users += 1
|
||||
flight.lock.acquire()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
flight.lock.release()
|
||||
with self._flight_guard:
|
||||
flight.users -= 1
|
||||
if flight.users == 0:
|
||||
self._flights.pop(key, None)
|
||||
|
||||
def _read_admitted_cache(
|
||||
self,
|
||||
session_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
cache_root = _private_directory(self.cache_root)
|
||||
session_cache = _private_child(cache_root, session_id)
|
||||
admission = self._load_admission(session_cache, session_id)
|
||||
latest = self._latest_descriptor(session_id)
|
||||
if latest is None:
|
||||
return None
|
||||
if len(jobs) > MAX_SCAN or len(candidates) > MAX_SCAN:
|
||||
if admission is None or admission.result_id != latest.result_id:
|
||||
recovered = self._recover_admission_from_cache(
|
||||
session_cache,
|
||||
latest,
|
||||
recording_id,
|
||||
)
|
||||
if recovered is not None:
|
||||
_, payload = recovered
|
||||
return payload
|
||||
admission = None
|
||||
if admission is None or admission.result_id != latest.result_id:
|
||||
return None
|
||||
result_cache = _private_child(session_cache, admission.result_id)
|
||||
output = result_cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
return _read_cache(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=admission.result_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
|
||||
def _load_admission(
|
||||
self,
|
||||
session_cache: Path,
|
||||
session_id: str,
|
||||
) -> _OverlayAdmission | None:
|
||||
path = session_cache / "admission.json"
|
||||
try:
|
||||
value = _read_object(path, session_cache)
|
||||
except (OSError, SessionIntegrityError):
|
||||
return None
|
||||
result_id = value.get("result_id")
|
||||
result_json_sha256 = value.get("result_json_sha256")
|
||||
created_at_utc = value.get("result_created_at_utc")
|
||||
if (
|
||||
value.get("schema_version") != OVERLAY_ADMISSION_SCHEMA
|
||||
or value.get("renderer_version") != OVERLAY_RENDERER_VERSION
|
||||
or value.get("session_id") != session_id
|
||||
or not isinstance(result_id, str)
|
||||
or _SAFE_RESULT_ID.fullmatch(result_id) is None
|
||||
or not isinstance(result_json_sha256, str)
|
||||
or _SHA256.fullmatch(result_json_sha256) is None
|
||||
or not isinstance(created_at_utc, str)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
descriptor = _read_result_descriptor(self.results_root / result_id)
|
||||
except (OSError, SessionIntegrityError):
|
||||
return None
|
||||
if (
|
||||
descriptor.session_id != session_id
|
||||
or descriptor.result_json_sha256 != result_json_sha256
|
||||
or descriptor.created_at_utc != created_at_utc
|
||||
):
|
||||
return None
|
||||
return _OverlayAdmission(
|
||||
session_id=session_id,
|
||||
result_id=result_id,
|
||||
result_json_sha256=result_json_sha256,
|
||||
result_created_at_utc=created_at_utc,
|
||||
)
|
||||
|
||||
def _recover_admission_from_cache(
|
||||
self,
|
||||
session_cache: Path,
|
||||
descriptor: _ResultDescriptor,
|
||||
recording_id: str,
|
||||
) -> tuple[_OverlayAdmission, bytes] | None:
|
||||
cache = _private_child(session_cache, descriptor.result_id)
|
||||
output = cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
payload = _read_cache(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=descriptor.result_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if payload is None:
|
||||
return None
|
||||
admission = _OverlayAdmission(
|
||||
session_id=descriptor.session_id,
|
||||
result_id=descriptor.result_id,
|
||||
result_json_sha256=descriptor.result_json_sha256,
|
||||
result_created_at_utc=descriptor.created_at_utc,
|
||||
)
|
||||
self._write_admission_document(session_cache, admission)
|
||||
return admission, payload
|
||||
|
||||
def _write_admission(self, result: IntegratedPerceptionResult) -> None:
|
||||
descriptor = _read_result_descriptor(result.result_root)
|
||||
if (
|
||||
descriptor.session_id != result.job.session_id
|
||||
or descriptor.result_id != result.result_id
|
||||
or descriptor.created_at_utc != result.created_at_utc
|
||||
):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception admission identity changed"
|
||||
)
|
||||
cache_root = _private_directory(self.cache_root)
|
||||
session_cache = _private_child(cache_root, descriptor.session_id)
|
||||
self._write_admission_document(
|
||||
session_cache,
|
||||
_OverlayAdmission(
|
||||
session_id=descriptor.session_id,
|
||||
result_id=descriptor.result_id,
|
||||
result_json_sha256=descriptor.result_json_sha256,
|
||||
result_created_at_utc=descriptor.created_at_utc,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write_admission_document(
|
||||
session_cache: Path,
|
||||
admission: _OverlayAdmission,
|
||||
) -> None:
|
||||
write_json_atomic(
|
||||
session_cache / "admission.json",
|
||||
{
|
||||
"schema_version": OVERLAY_ADMISSION_SCHEMA,
|
||||
"renderer_version": OVERLAY_RENDERER_VERSION,
|
||||
"session_id": admission.session_id,
|
||||
"result_id": admission.result_id,
|
||||
"result_json_sha256": admission.result_json_sha256,
|
||||
"result_created_at_utc": admission.result_created_at_utc,
|
||||
},
|
||||
)
|
||||
|
||||
def _latest_descriptor(self, session_id: str) -> _ResultDescriptor | None:
|
||||
try:
|
||||
metadata = self.results_root.stat()
|
||||
entries = tuple(sorted(self.results_root.iterdir(), key=lambda path: path.name))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if len(entries) > MAX_SCAN:
|
||||
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
|
||||
matches = []
|
||||
for job_root in jobs:
|
||||
if job_root.is_symlink():
|
||||
continue
|
||||
try:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if job.session_id != session_id:
|
||||
continue
|
||||
for candidate in candidates:
|
||||
generation = (metadata.st_mtime_ns, len(entries))
|
||||
with self._catalog_lock:
|
||||
if generation != self._descriptor_catalog_generation:
|
||||
self._descriptor_catalog_generation = generation
|
||||
self._descriptors_by_session.clear()
|
||||
self._latest_by_session.clear()
|
||||
existing = self._descriptors_by_session.get(session_id)
|
||||
if existing is not None:
|
||||
return existing[0] if existing else None
|
||||
matches: list[_ResultDescriptor] = []
|
||||
for candidate in entries:
|
||||
if candidate.is_symlink() or _SAFE_RESULT_ID.fullmatch(candidate.name) is None:
|
||||
continue
|
||||
try:
|
||||
value = validate_integrated_perception_result(
|
||||
job_root,
|
||||
candidate,
|
||||
self.lidar_packs_root,
|
||||
)
|
||||
descriptor = _read_result_descriptor(candidate)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if (
|
||||
value.accepted
|
||||
and value.publication_scope == "recorded-integrated-realtime-qualification-only"
|
||||
):
|
||||
matches.append(value)
|
||||
if not matches:
|
||||
self._latest_by_session[session_id] = None
|
||||
return None
|
||||
latest = max(matches, key=lambda value: (value.created_at_utc, value.result_id))
|
||||
self._latest_by_session[session_id] = latest
|
||||
return latest
|
||||
|
||||
|
||||
def _directory_catalog_signature(*roots: Path) -> tuple[Any, ...]:
|
||||
"""Track immutable lab catalogs without rehashing every large artifact per request.
|
||||
|
||||
Accepted result directories are append-only. Their directory mtimes still
|
||||
change when an atomic manifest replacement occurs, while a new experiment
|
||||
changes the parent listing. This cheap signature therefore invalidates the
|
||||
process-local validation memo without weakening the first full integrity
|
||||
validation of every catalog generation.
|
||||
"""
|
||||
|
||||
signature: list[Any] = []
|
||||
for root in roots:
|
||||
try:
|
||||
root_stat = root.stat()
|
||||
entries = sorted(root.iterdir(), key=lambda path: path.name)
|
||||
except FileNotFoundError:
|
||||
signature.append((str(root), None))
|
||||
continue
|
||||
if len(entries) > MAX_SCAN:
|
||||
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
|
||||
entry_signature = []
|
||||
for entry in entries:
|
||||
try:
|
||||
metadata = entry.lstat()
|
||||
except FileNotFoundError:
|
||||
# A concurrent atomic publication will alter the parent mtime
|
||||
# and be observed on the next request.
|
||||
continue
|
||||
entry_signature.append(
|
||||
(
|
||||
entry.name,
|
||||
metadata.st_mode,
|
||||
metadata.st_size,
|
||||
metadata.st_mtime_ns,
|
||||
if descriptor.session_id == session_id:
|
||||
matches.append(descriptor)
|
||||
ordered = tuple(
|
||||
sorted(
|
||||
matches,
|
||||
key=lambda value: (value.created_at_utc, value.result_id),
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
signature.append(
|
||||
(
|
||||
str(root),
|
||||
root_stat.st_mtime_ns,
|
||||
tuple(entry_signature),
|
||||
)
|
||||
)
|
||||
return tuple(signature)
|
||||
self._descriptors_by_session[session_id] = ordered
|
||||
return ordered[0] if ordered else None
|
||||
|
||||
def _latest(self, session_id: str) -> IntegratedPerceptionResult | None:
|
||||
latest_descriptor = self._latest_descriptor(session_id)
|
||||
if latest_descriptor is None:
|
||||
return None
|
||||
with self._catalog_lock:
|
||||
if session_id in self._latest_by_session:
|
||||
return self._latest_by_session[session_id]
|
||||
descriptors = self._descriptors_by_session.get(session_id, ())
|
||||
for descriptor in descriptors:
|
||||
try:
|
||||
value = validate_integrated_perception_result(
|
||||
self.jobs_root / descriptor.job_id,
|
||||
descriptor.result_root,
|
||||
self.lidar_packs_root,
|
||||
)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if (
|
||||
value.accepted
|
||||
and value.publication_scope == "recorded-integrated-realtime-qualification-only"
|
||||
):
|
||||
with self._catalog_lock:
|
||||
self._latest_by_session[session_id] = value
|
||||
return value
|
||||
with self._catalog_lock:
|
||||
self._latest_by_session[session_id] = None
|
||||
return None
|
||||
|
||||
|
||||
def _render(
|
||||
@@ -890,10 +1142,52 @@ def _read_rows(path: Path, root: Path, schema: str) -> list[dict[str, Any]]:
|
||||
return rows
|
||||
|
||||
|
||||
def _read_result_descriptor(result_root: Path) -> _ResultDescriptor:
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("integrated perception result descriptor is invalid")
|
||||
result_path = root / "result.json"
|
||||
result = _read_object(result_path, root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
created_at_utc = result.get("created_at_utc")
|
||||
session_id = identity.get("session_id") if isinstance(identity, dict) else None
|
||||
job_id = identity.get("job_id") if isinstance(identity, dict) else None
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"e10-integrated-perception-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or result.get("acceptance_state") != "accepted"
|
||||
or result.get("publication_scope")
|
||||
!= "recorded-integrated-realtime-qualification-only"
|
||||
or not isinstance(session_id, str)
|
||||
or _SAFE_RECORDING_ID.fullmatch(session_id) is None
|
||||
or not isinstance(job_id, str)
|
||||
or _SAFE_JOB_ID.fullmatch(job_id) is None
|
||||
or not isinstance(created_at_utc, str)
|
||||
or not 1 <= len(created_at_utc) <= 64
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception result is not admitted")
|
||||
return _ResultDescriptor(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
result_json_sha256=_sha256(result_path),
|
||||
session_id=session_id,
|
||||
job_id=job_id,
|
||||
created_at_utc=created_at_utc,
|
||||
)
|
||||
|
||||
|
||||
def _read_cache(
|
||||
output: Path,
|
||||
sidecar: Path,
|
||||
result: IntegratedPerceptionResult,
|
||||
*,
|
||||
result_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
try:
|
||||
@@ -902,9 +1196,9 @@ def _read_cache(
|
||||
except (OSError, SessionIntegrityError):
|
||||
return None
|
||||
if (
|
||||
value.get("schema_version") != "missioncore.e10-overlay-cache/v1"
|
||||
value.get("schema_version") != OVERLAY_CACHE_SCHEMA
|
||||
or value.get("renderer_version") != OVERLAY_RENDERER_VERSION
|
||||
or value.get("result_id") != result.result_id
|
||||
or value.get("result_id") != result_id
|
||||
or value.get("recording_id") != recording_id
|
||||
or value.get("byte_length") != len(payload)
|
||||
or value.get("sha256") != hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@@ -55,6 +55,7 @@ JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
RECORDED_VIEW_POINT_DECIMATION_THRESHOLD = 100_000
|
||||
RECORDED_VIEW_POINT_STRIDE = 4
|
||||
RECORDED_VIEW_POINT_FRAME_STRIDE = 5
|
||||
RECORDED_RRD_IDENTITY_VERSION = "missioncore.recorded-rrd/v1"
|
||||
|
||||
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||
# blueprint update makes the update overwrite the existing scene instead of
|
||||
@@ -216,13 +217,26 @@ def export_k1mqtt_to_rrd(
|
||||
_validate_paths(source, destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recording_id = str(uuid4())
|
||||
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
|
||||
source_sha256 = _sha256_file(
|
||||
source,
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback,
|
||||
)
|
||||
capture_clock = _optional_capture_clock(source, capture_clock_path)
|
||||
capture_clock_origin = _optional_capture_clock_origin(
|
||||
source,
|
||||
capture_clock_origin_path,
|
||||
)
|
||||
# Rerun uses this identity to merge independently delivered base,
|
||||
# blueprint and perception streams. Bind it to immutable source evidence
|
||||
# so regenerating an unchanged recording does not invalidate every derived
|
||||
# overlay cache.
|
||||
recording_id = _stable_recording_id(
|
||||
source_sha256,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
)
|
||||
temporary = destination.with_name(f".{destination.name}.{uuid4()}.tmp")
|
||||
settings = RerunSceneSettings()
|
||||
blueprint = _recorded_blueprint(settings)
|
||||
recording: rr.RecordingStream | None = None
|
||||
@@ -231,11 +245,6 @@ def export_k1mqtt_to_rrd(
|
||||
|
||||
counters = _ExportCounters()
|
||||
trajectory = _TrajectoryBuffer.empty()
|
||||
capture_clock = _optional_capture_clock(source, capture_clock_path)
|
||||
capture_clock_origin = _optional_capture_clock_origin(
|
||||
source,
|
||||
capture_clock_origin_path,
|
||||
)
|
||||
if (
|
||||
capture_clock is not None
|
||||
and capture_clock_origin is not None
|
||||
@@ -449,6 +458,31 @@ def _validate_paths(source: Path, destination: Path) -> None:
|
||||
raise RrdExportError("RRD export accepts only native K1MQTT captures")
|
||||
|
||||
|
||||
def _stable_recording_id(
|
||||
source_sha256: str,
|
||||
capture_clock: CaptureClockEnvelope | None,
|
||||
capture_clock_origin: CaptureClockOrigin | None,
|
||||
) -> str:
|
||||
identity = hashlib.sha256()
|
||||
for value in (
|
||||
RECORDED_RRD_IDENTITY_VERSION,
|
||||
source_sha256,
|
||||
str(capture_clock.started_at_epoch_ns) if capture_clock is not None else "-",
|
||||
str(capture_clock.started_monotonic_ns) if capture_clock is not None else "-",
|
||||
str(capture_clock.completed_at_epoch_ns) if capture_clock is not None else "-",
|
||||
str(capture_clock.completed_monotonic_ns) if capture_clock is not None else "-",
|
||||
str(capture_clock_origin.started_at_epoch_ns)
|
||||
if capture_clock_origin is not None
|
||||
else "-",
|
||||
str(capture_clock_origin.started_monotonic_ns)
|
||||
if capture_clock_origin is not None
|
||||
else "-",
|
||||
):
|
||||
identity.update(value.encode("ascii"))
|
||||
identity.update(b"\0")
|
||||
return f"k1-{identity.hexdigest()}"
|
||||
|
||||
|
||||
def _optional_capture_clock(
|
||||
source: Path,
|
||||
explicit_path: Path | None,
|
||||
|
||||
@@ -130,6 +130,23 @@ class RecordedPerceptionRequest(StrictApiModel):
|
||||
)
|
||||
|
||||
|
||||
class RecordedPerceptionPreparationStatus(StrictApiModel):
|
||||
state: Literal["idle", "preparing", "ready", "unavailable", "error"]
|
||||
phase: Literal[
|
||||
"idle",
|
||||
"cache-lookup",
|
||||
"queued",
|
||||
"artifact-validation",
|
||||
"rendering",
|
||||
"cache-write",
|
||||
"ready",
|
||||
"unavailable",
|
||||
"error",
|
||||
]
|
||||
elapsed_seconds: float = Field(ge=0.0)
|
||||
byte_length: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class RecordedPointColorsRequest(StrictApiModel):
|
||||
application_id: Literal["nodedc_mission_core_recorded"]
|
||||
recording_id: str = Field(
|
||||
@@ -915,6 +932,61 @@ def build_session_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/api/v1/observation-sessions/{session_id}/perception/status")
|
||||
async def get_observation_session_perception_status(
|
||||
session_id: str,
|
||||
response: Response,
|
||||
recording_id: Annotated[
|
||||
str,
|
||||
Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
|
||||
),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_prepare_replay,
|
||||
store,
|
||||
catalog_refresher,
|
||||
session_id,
|
||||
1.0,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
status_provider = (
|
||||
getattr(perception_overlay_provider, "status", None)
|
||||
if perception_overlay_provider is not None
|
||||
else None
|
||||
)
|
||||
value: object = (
|
||||
await run_in_threadpool(
|
||||
status_provider,
|
||||
session_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if callable(status_provider)
|
||||
else {
|
||||
"state": "idle",
|
||||
"phase": "idle",
|
||||
"elapsed_seconds": 0.0,
|
||||
"byte_length": None,
|
||||
}
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return RecordedPerceptionPreparationStatus.model_validate(value).model_dump()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Сервис слоя распознавания вернул некорректный статус.",
|
||||
) from exc
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/point-colors.rrd")
|
||||
async def get_observation_session_point_colors(
|
||||
session_id: str,
|
||||
|
||||
Reference in New Issue
Block a user