fix(replay): serve admitted AI overlays without revalidation
This commit is contained in:
@@ -10,6 +10,10 @@ import {
|
|||||||
} from "../core/observation/liveReceiverWatchdog";
|
} from "../core/observation/liveReceiverWatchdog";
|
||||||
import { postLiveViewerDiagnostic } from "../core/observation/liveViewerDiagnostics";
|
import { postLiveViewerDiagnostic } from "../core/observation/liveViewerDiagnostics";
|
||||||
import type { LiveViewerFailureStage } from "../core/observation/liveViewerDiagnostics";
|
import type { LiveViewerFailureStage } from "../core/observation/liveViewerDiagnostics";
|
||||||
|
import {
|
||||||
|
fetchPerceptionPreparationStatus,
|
||||||
|
perceptionPreparationMessage,
|
||||||
|
} from "../core/observation/perceptionPreparation";
|
||||||
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
||||||
|
|
||||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||||
@@ -1575,6 +1579,9 @@ export function RerunViewport({
|
|||||||
}
|
}
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
let lastReportedPercent = -1;
|
let lastReportedPercent = -1;
|
||||||
|
let requestSettled = false;
|
||||||
|
let transferStarted = false;
|
||||||
|
let statusTimer: number | null = null;
|
||||||
onPerceptionLoadChange?.({
|
onPerceptionLoadChange?.({
|
||||||
phase: "loading",
|
phase: "loading",
|
||||||
receivedBytes: 0,
|
receivedBytes: 0,
|
||||||
@@ -1582,10 +1589,51 @@ export function RerunViewport({
|
|||||||
progress: null,
|
progress: null,
|
||||||
message: "Сервер готовит AI-слои.",
|
message: "Сервер готовит AI-слои.",
|
||||||
});
|
});
|
||||||
|
const pollPreparationStatus = () => {
|
||||||
|
void fetchPerceptionPreparationStatus(
|
||||||
|
recordedPerceptionUrl,
|
||||||
|
identity.recordingId,
|
||||||
|
{
|
||||||
|
origin: window.location.origin,
|
||||||
|
signal: abort.signal,
|
||||||
|
},
|
||||||
|
).then((status) => {
|
||||||
|
if (
|
||||||
|
abort.signal.aborted ||
|
||||||
|
requestSettled ||
|
||||||
|
transferStarted ||
|
||||||
|
perceptionChannelRef.current !== active
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = perceptionPreparationMessage(status.phase);
|
||||||
|
if (message) {
|
||||||
|
onPerceptionLoadChange?.({
|
||||||
|
phase: "loading",
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: status.byteLength,
|
||||||
|
progress: null,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// The primary RRD request remains authoritative when status polling is unavailable.
|
||||||
|
}).finally(() => {
|
||||||
|
if (!abort.signal.aborted && !requestSettled && !transferStarted) {
|
||||||
|
statusTimer = window.setTimeout(pollPreparationStatus, 750);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
pollPreparationStatus();
|
||||||
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
onProgress: (receivedBytes, totalBytes) => {
|
onProgress: (receivedBytes, totalBytes) => {
|
||||||
|
transferStarted = true;
|
||||||
|
if (statusTimer !== null) {
|
||||||
|
window.clearTimeout(statusTimer);
|
||||||
|
statusTimer = null;
|
||||||
|
}
|
||||||
const progress = totalBytes > 0 ? receivedBytes / totalBytes : null;
|
const progress = totalBytes > 0 ? receivedBytes / totalBytes : null;
|
||||||
const percent = progress === null ? -1 : Math.floor(progress * 100);
|
const percent = progress === null ? -1 : Math.floor(progress * 100);
|
||||||
if (percent === lastReportedPercent && receivedBytes !== totalBytes) return;
|
if (percent === lastReportedPercent && receivedBytes !== totalBytes) return;
|
||||||
@@ -1640,8 +1688,18 @@ export function RerunViewport({
|
|||||||
: "AI-слои недоступны.",
|
: "AI-слои недоступны.",
|
||||||
});
|
});
|
||||||
// The base recording remains available when no admitted perception layer exists.
|
// The base recording remains available when no admitted perception layer exists.
|
||||||
|
}).finally(() => {
|
||||||
|
requestSettled = true;
|
||||||
|
if (statusTimer !== null) {
|
||||||
|
window.clearTimeout(statusTimer);
|
||||||
|
statusTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => abort.abort();
|
return () => {
|
||||||
|
requestSettled = true;
|
||||||
|
if (statusTimer !== null) window.clearTimeout(statusTimer);
|
||||||
|
abort.abort();
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
onPerceptionLoadChange,
|
onPerceptionLoadChange,
|
||||||
perceptionChannelRevision,
|
perceptionChannelRevision,
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
export type PerceptionPreparationPhase =
|
||||||
|
| "idle"
|
||||||
|
| "cache-lookup"
|
||||||
|
| "queued"
|
||||||
|
| "artifact-validation"
|
||||||
|
| "rendering"
|
||||||
|
| "cache-write"
|
||||||
|
| "ready"
|
||||||
|
| "unavailable"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface PerceptionPreparationStatus {
|
||||||
|
state: "idle" | "preparing" | "ready" | "unavailable" | "error";
|
||||||
|
phase: PerceptionPreparationPhase;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
byteLength: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORDED_PERCEPTION_PATH =
|
||||||
|
/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
|
||||||
|
const SAFE_RECORDING_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
|
||||||
|
export async function fetchPerceptionPreparationStatus(
|
||||||
|
endpointUrl: string,
|
||||||
|
recordingId: string,
|
||||||
|
{
|
||||||
|
origin,
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
origin: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: typeof globalThis.fetch;
|
||||||
|
},
|
||||||
|
): Promise<PerceptionPreparationStatus> {
|
||||||
|
const base = new URL(origin);
|
||||||
|
const endpoint = new URL(endpointUrl, base.origin);
|
||||||
|
if (
|
||||||
|
endpoint.origin !== base.origin ||
|
||||||
|
endpoint.search ||
|
||||||
|
endpoint.hash ||
|
||||||
|
!RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||||
|
!SAFE_RECORDING_ID.test(recordingId)
|
||||||
|
) {
|
||||||
|
throw new Error("Unsafe perception preparation status request");
|
||||||
|
}
|
||||||
|
endpoint.pathname = endpoint.pathname.replace(/\/perception\.rrd$/, "/perception/status");
|
||||||
|
endpoint.searchParams.set("recording_id", recordingId);
|
||||||
|
const response = await fetcher(endpoint.href, {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Perception preparation status is unavailable");
|
||||||
|
}
|
||||||
|
const value: unknown = await response.json();
|
||||||
|
if (!isPerceptionPreparationStatus(value)) {
|
||||||
|
throw new Error("Invalid perception preparation status");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: value.state,
|
||||||
|
phase: value.phase,
|
||||||
|
elapsedSeconds: value.elapsed_seconds,
|
||||||
|
byteLength: value.byte_length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function perceptionPreparationMessage(
|
||||||
|
phase: PerceptionPreparationPhase,
|
||||||
|
): string | null {
|
||||||
|
switch (phase) {
|
||||||
|
case "cache-lookup":
|
||||||
|
return "Проверяем готовый AI-слой.";
|
||||||
|
case "queued":
|
||||||
|
return "AI-слой ожидает последовательной подготовки.";
|
||||||
|
case "artifact-validation":
|
||||||
|
return "Проверяем опубликованный AI-результат.";
|
||||||
|
case "rendering":
|
||||||
|
return "Формируем AI-слой.";
|
||||||
|
case "cache-write":
|
||||||
|
return "Сохраняем AI-слой.";
|
||||||
|
case "ready":
|
||||||
|
return "AI-слой готов. Загружаем.";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPerceptionPreparationStatus(
|
||||||
|
value: unknown,
|
||||||
|
): value is {
|
||||||
|
state: PerceptionPreparationStatus["state"];
|
||||||
|
phase: PerceptionPreparationPhase;
|
||||||
|
elapsed_seconds: number;
|
||||||
|
byte_length: number | null;
|
||||||
|
} {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
["idle", "preparing", "ready", "unavailable", "error"].includes(
|
||||||
|
String(candidate.state),
|
||||||
|
) &&
|
||||||
|
[
|
||||||
|
"idle",
|
||||||
|
"cache-lookup",
|
||||||
|
"queued",
|
||||||
|
"artifact-validation",
|
||||||
|
"rendering",
|
||||||
|
"cache-write",
|
||||||
|
"ready",
|
||||||
|
"unavailable",
|
||||||
|
"error",
|
||||||
|
].includes(String(candidate.phase)) &&
|
||||||
|
typeof candidate.elapsed_seconds === "number" &&
|
||||||
|
Number.isFinite(candidate.elapsed_seconds) &&
|
||||||
|
candidate.elapsed_seconds >= 0 &&
|
||||||
|
(candidate.byte_length === null ||
|
||||||
|
(typeof candidate.byte_length === "number" &&
|
||||||
|
Number.isSafeInteger(candidate.byte_length) &&
|
||||||
|
candidate.byte_length >= 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let fetchPerceptionPreparationStatus;
|
||||||
|
let perceptionPreparationMessage;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
fetchPerceptionPreparationStatus,
|
||||||
|
perceptionPreparationMessage,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
|
"/src/core/observation/perceptionPreparation.ts",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("perception preparation status is same-origin, typed and phase-specific", async () => {
|
||||||
|
let requested;
|
||||||
|
const status = await fetchPerceptionPreparationStatus(
|
||||||
|
"/api/v1/observation-sessions/session-1/perception.rrd",
|
||||||
|
"recording-001",
|
||||||
|
{
|
||||||
|
origin: "http://127.0.0.1:8000",
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
requested = { url: String(input), method: init.method };
|
||||||
|
return Response.json({
|
||||||
|
state: "preparing",
|
||||||
|
phase: "artifact-validation",
|
||||||
|
elapsed_seconds: 2.5,
|
||||||
|
byte_length: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(requested, {
|
||||||
|
url: "http://127.0.0.1:8000/api/v1/observation-sessions/session-1/perception/status?recording_id=recording-001",
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
assert.deepEqual(status, {
|
||||||
|
state: "preparing",
|
||||||
|
phase: "artifact-validation",
|
||||||
|
elapsedSeconds: 2.5,
|
||||||
|
byteLength: null,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
perceptionPreparationMessage(status.phase),
|
||||||
|
"Проверяем опубликованный AI-результат.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("perception preparation status rejects cross-origin endpoints", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
fetchPerceptionPreparationStatus(
|
||||||
|
"https://example.com/api/v1/observation-sessions/session-1/perception.rrd",
|
||||||
|
"recording-001",
|
||||||
|
{
|
||||||
|
origin: "http://127.0.0.1:8000",
|
||||||
|
fetcher: async () => {
|
||||||
|
throw new Error("must not fetch");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/Unsafe perception preparation status request/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -201,6 +201,19 @@ class RecordedPerceptionOverlayMux:
|
|||||||
recording_id=recording_id,
|
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(
|
def validate_recorded_calibrated_fusion(
|
||||||
job_root: Path,
|
job_root: Path,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import stat
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from contextlib import suppress
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager, suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol, TypeGuard
|
from typing import Any, Protocol, TypeGuard
|
||||||
@@ -39,10 +41,13 @@ MAX_JSON_BYTES = 64 * 1024 * 1024
|
|||||||
MAX_LINE_BYTES = 4 * 1024 * 1024
|
MAX_LINE_BYTES = 4 * 1024 * 1024
|
||||||
MAX_SOURCE_BYTES = 512 * 1024 * 1024
|
MAX_SOURCE_BYTES = 512 * 1024 * 1024
|
||||||
OVERLAY_RENDERER_VERSION = "4"
|
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
|
CUBOID_PRESENTATION_HOLD_NS = 500_000_000
|
||||||
|
|
||||||
_SAFE_RESULT_ID = re.compile(r"^e10-integrated-perception-[a-f0-9]{64}$")
|
_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_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}$")
|
_SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
|
||||||
@@ -67,6 +72,30 @@ class IntegratedPerceptionResult:
|
|||||||
report_path: Path
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _PresentedCuboid:
|
class _PresentedCuboid:
|
||||||
observed_ns: int
|
observed_ns: int
|
||||||
@@ -329,7 +358,13 @@ def validate_integrated_perception_result(
|
|||||||
|
|
||||||
|
|
||||||
class IntegratedPerceptionOverlayStore:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -345,9 +380,15 @@ class IntegratedPerceptionOverlayStore:
|
|||||||
self.lidar_packs_root = lidar_packs_root.expanduser().absolute()
|
self.lidar_packs_root = lidar_packs_root.expanduser().absolute()
|
||||||
self.cache_root = cache_root.expanduser().absolute()
|
self.cache_root = cache_root.expanduser().absolute()
|
||||||
self.ffmpeg_path = ffmpeg_path.expanduser().absolute()
|
self.ffmpeg_path = ffmpeg_path.expanduser().absolute()
|
||||||
self._lock = threading.Lock()
|
self._catalog_lock = threading.Lock()
|
||||||
self._catalog_signature: tuple[Any, ...] | None = None
|
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._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(
|
def render(
|
||||||
self,
|
self,
|
||||||
@@ -363,146 +404,357 @@ class IntegratedPerceptionOverlayStore:
|
|||||||
or _SAFE_RECORDING_ID.fullmatch(recording_id) is None
|
or _SAFE_RECORDING_ID.fullmatch(recording_id) is None
|
||||||
):
|
):
|
||||||
raise ValueError("integrated perception recording id is invalid")
|
raise ValueError("integrated perception recording id is invalid")
|
||||||
with self._lock:
|
key = (session_id, recording_id)
|
||||||
result = self._latest(session_id)
|
self._set_status(key, state="preparing", phase="cache-lookup")
|
||||||
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]
|
|
||||||
try:
|
try:
|
||||||
jobs = sorted(self.jobs_root.iterdir())
|
with self._single_flight(key):
|
||||||
candidates = sorted(self.results_root.iterdir())
|
cached = self._read_admitted_cache(session_id, recording_id)
|
||||||
except FileNotFoundError:
|
if cached is not None:
|
||||||
self._latest_by_session[session_id] = 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
|
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")
|
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
|
||||||
matches = []
|
generation = (metadata.st_mtime_ns, len(entries))
|
||||||
for job_root in jobs:
|
with self._catalog_lock:
|
||||||
if job_root.is_symlink():
|
if generation != self._descriptor_catalog_generation:
|
||||||
continue
|
self._descriptor_catalog_generation = generation
|
||||||
try:
|
self._descriptors_by_session.clear()
|
||||||
job = validate_camera_compute_job(job_root)
|
self._latest_by_session.clear()
|
||||||
except (OSError, SessionIntegrityError):
|
existing = self._descriptors_by_session.get(session_id)
|
||||||
continue
|
if existing is not None:
|
||||||
if job.session_id != session_id:
|
return existing[0] if existing else None
|
||||||
continue
|
matches: list[_ResultDescriptor] = []
|
||||||
for candidate in candidates:
|
for candidate in entries:
|
||||||
if candidate.is_symlink() or _SAFE_RESULT_ID.fullmatch(candidate.name) is None:
|
if candidate.is_symlink() or _SAFE_RESULT_ID.fullmatch(candidate.name) is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
value = validate_integrated_perception_result(
|
descriptor = _read_result_descriptor(candidate)
|
||||||
job_root,
|
|
||||||
candidate,
|
|
||||||
self.lidar_packs_root,
|
|
||||||
)
|
|
||||||
except (OSError, SessionIntegrityError):
|
except (OSError, SessionIntegrityError):
|
||||||
continue
|
continue
|
||||||
if (
|
if descriptor.session_id == session_id:
|
||||||
value.accepted
|
matches.append(descriptor)
|
||||||
and value.publication_scope == "recorded-integrated-realtime-qualification-only"
|
ordered = tuple(
|
||||||
):
|
sorted(
|
||||||
matches.append(value)
|
matches,
|
||||||
if not matches:
|
key=lambda value: (value.created_at_utc, value.result_id),
|
||||||
self._latest_by_session[session_id] = None
|
reverse=True,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
signature.append(
|
self._descriptors_by_session[session_id] = ordered
|
||||||
(
|
return ordered[0] if ordered else None
|
||||||
str(root),
|
|
||||||
root_stat.st_mtime_ns,
|
def _latest(self, session_id: str) -> IntegratedPerceptionResult | None:
|
||||||
tuple(entry_signature),
|
latest_descriptor = self._latest_descriptor(session_id)
|
||||||
)
|
if latest_descriptor is None:
|
||||||
)
|
return None
|
||||||
return tuple(signature)
|
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(
|
def _render(
|
||||||
@@ -890,10 +1142,52 @@ def _read_rows(path: Path, root: Path, schema: str) -> list[dict[str, Any]]:
|
|||||||
return rows
|
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(
|
def _read_cache(
|
||||||
output: Path,
|
output: Path,
|
||||||
sidecar: Path,
|
sidecar: Path,
|
||||||
result: IntegratedPerceptionResult,
|
*,
|
||||||
|
result_id: str,
|
||||||
recording_id: str,
|
recording_id: str,
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
try:
|
try:
|
||||||
@@ -902,9 +1196,9 @@ def _read_cache(
|
|||||||
except (OSError, SessionIntegrityError):
|
except (OSError, SessionIntegrityError):
|
||||||
return None
|
return None
|
||||||
if (
|
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("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("recording_id") != recording_id
|
||||||
or value.get("byte_length") != len(payload)
|
or value.get("byte_length") != len(payload)
|
||||||
or value.get("sha256") != hashlib.sha256(payload).hexdigest()
|
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_DECIMATION_THRESHOLD = 100_000
|
||||||
RECORDED_VIEW_POINT_STRIDE = 4
|
RECORDED_VIEW_POINT_STRIDE = 4
|
||||||
RECORDED_VIEW_POINT_FRAME_STRIDE = 5
|
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
|
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||||
# blueprint update makes the update overwrite the existing scene instead of
|
# blueprint update makes the update overwrite the existing scene instead of
|
||||||
@@ -216,13 +217,26 @@ def export_k1mqtt_to_rrd(
|
|||||||
_validate_paths(source, destination)
|
_validate_paths(source, destination)
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
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_sha256 = _sha256_file(
|
||||||
source,
|
source,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
activity_callback=activity_callback,
|
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()
|
settings = RerunSceneSettings()
|
||||||
blueprint = _recorded_blueprint(settings)
|
blueprint = _recorded_blueprint(settings)
|
||||||
recording: rr.RecordingStream | None = None
|
recording: rr.RecordingStream | None = None
|
||||||
@@ -231,11 +245,6 @@ def export_k1mqtt_to_rrd(
|
|||||||
|
|
||||||
counters = _ExportCounters()
|
counters = _ExportCounters()
|
||||||
trajectory = _TrajectoryBuffer.empty()
|
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 (
|
if (
|
||||||
capture_clock is not None
|
capture_clock is not None
|
||||||
and capture_clock_origin 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")
|
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(
|
def _optional_capture_clock(
|
||||||
source: Path,
|
source: Path,
|
||||||
explicit_path: Path | None,
|
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):
|
class RecordedPointColorsRequest(StrictApiModel):
|
||||||
application_id: Literal["nodedc_mission_core_recorded"]
|
application_id: Literal["nodedc_mission_core_recorded"]
|
||||||
recording_id: str = Field(
|
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")
|
@router.post("/api/v1/observation-sessions/{session_id}/point-colors.rrd")
|
||||||
async def get_observation_session_point_colors(
|
async def get_observation_session_point_colors(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@@ -15,6 +20,44 @@ from k1link.compute.integrated_perception import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_result_descriptor(
|
||||||
|
results_root: Path,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
created_at_utc: str,
|
||||||
|
) -> Path:
|
||||||
|
identity = {
|
||||||
|
"schema_version": integrated_module.IDENTITY_SCHEMA,
|
||||||
|
"job_id": f"recorded-camera-{'1' * 24}",
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
identity,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode()
|
||||||
|
).hexdigest()
|
||||||
|
result_id = f"e10-integrated-perception-{identity_sha256}"
|
||||||
|
root = results_root / result_id
|
||||||
|
root.mkdir()
|
||||||
|
(root / "result.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": integrated_module.RESULT_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity": identity,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"acceptance_state": "accepted",
|
||||||
|
"publication_scope": "recorded-integrated-realtime-qualification-only",
|
||||||
|
"created_at_utc": created_at_utc,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
def _worker_modules() -> tuple[object, object]:
|
def _worker_modules() -> tuple[object, object]:
|
||||||
root = Path(__file__).resolve().parents[1] / "experiments" / "perception"
|
root = Path(__file__).resolve().parents[1] / "experiments" / "perception"
|
||||||
worker = root / "worker"
|
worker = root / "worker"
|
||||||
@@ -41,7 +84,7 @@ def _worker_modules() -> tuple[object, object]:
|
|||||||
sys.path.pop(0)
|
sys.path.pop(0)
|
||||||
|
|
||||||
|
|
||||||
def test_integrated_overlay_catalog_validation_is_memoized_until_publication(
|
def test_integrated_overlay_recovers_admission_from_sealed_cache_without_revalidation(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -51,32 +94,37 @@ def test_integrated_overlay_catalog_validation_is_memoized_until_publication(
|
|||||||
cache_root = tmp_path / "cache"
|
cache_root = tmp_path / "cache"
|
||||||
for root in (jobs_root, results_root, lidar_packs_root):
|
for root in (jobs_root, results_root, lidar_packs_root):
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
job_root = jobs_root / "job-1"
|
result = _write_result_descriptor(
|
||||||
job_root.mkdir()
|
results_root,
|
||||||
first_result = results_root / f"e10-integrated-perception-{'a' * 64}"
|
session_id="session-1",
|
||||||
first_result.mkdir()
|
created_at_utc="2026-07-29T12:00:00Z",
|
||||||
|
)
|
||||||
validation_calls = {"job": 0, "result": 0}
|
payload = b"RRF2sealed-overlay"
|
||||||
job = SimpleNamespace(session_id="session-1")
|
recording_id = "recording-1"
|
||||||
|
cache = cache_root / "session-1" / result.name
|
||||||
def validate_job(_root: Path) -> object:
|
cache.mkdir(parents=True)
|
||||||
validation_calls["job"] += 1
|
output = cache / f"{recording_id}.rrd"
|
||||||
return job
|
output.write_bytes(payload)
|
||||||
|
(cache / f"{recording_id}.rrd.cache.json").write_text(
|
||||||
def validate_result(_job_root: Path, candidate: Path, _packs: Path) -> object:
|
json.dumps(
|
||||||
validation_calls["result"] += 1
|
{
|
||||||
return SimpleNamespace(
|
"schema_version": integrated_module.OVERLAY_CACHE_SCHEMA,
|
||||||
accepted=True,
|
"renderer_version": integrated_module.OVERLAY_RENDERER_VERSION,
|
||||||
publication_scope="recorded-integrated-realtime-qualification-only",
|
"result_id": result.name,
|
||||||
created_at_utc=candidate.name,
|
"recording_id": recording_id,
|
||||||
result_id=candidate.name,
|
"byte_length": len(payload),
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def reject_revalidation(*_args: object, **_kwargs: object) -> object:
|
||||||
|
raise AssertionError("sealed presentation cache must not revalidate source artifacts")
|
||||||
|
|
||||||
monkeypatch.setattr(integrated_module, "validate_camera_compute_job", validate_job)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
integrated_module,
|
integrated_module,
|
||||||
"validate_integrated_perception_result",
|
"validate_integrated_perception_result",
|
||||||
validate_result,
|
reject_revalidation,
|
||||||
)
|
)
|
||||||
store = IntegratedPerceptionOverlayStore(
|
store = IntegratedPerceptionOverlayStore(
|
||||||
jobs_root=jobs_root,
|
jobs_root=jobs_root,
|
||||||
@@ -86,13 +134,75 @@ def test_integrated_overlay_catalog_validation_is_memoized_until_publication(
|
|||||||
ffmpeg_path=tmp_path / "ffmpeg",
|
ffmpeg_path=tmp_path / "ffmpeg",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert store._latest("session-1") is not None
|
assert (
|
||||||
assert store._latest("session-1") is not None
|
store.render(
|
||||||
assert validation_calls == {"job": 1, "result": 1}
|
"session-1",
|
||||||
|
application_id="nodedc_mission_core_recorded",
|
||||||
|
recording_id=recording_id,
|
||||||
|
)
|
||||||
|
== payload
|
||||||
|
)
|
||||||
|
admission = json.loads((cache_root / "session-1" / "admission.json").read_text())
|
||||||
|
assert admission["result_id"] == result.name
|
||||||
|
assert store.status("session-1", recording_id=recording_id) == {
|
||||||
|
"state": "ready",
|
||||||
|
"phase": "ready",
|
||||||
|
"elapsed_seconds": pytest.approx(0.0, abs=0.1),
|
||||||
|
"byte_length": len(payload),
|
||||||
|
}
|
||||||
|
|
||||||
(results_root / f"e10-integrated-perception-{'b' * 64}").mkdir()
|
|
||||||
assert store._latest("session-1") is not None
|
def test_integrated_overlay_serializes_heavy_materialization_across_sessions(
|
||||||
assert validation_calls == {"job": 2, "result": 3}
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
for name in ("jobs", "results", "lidar-packs"):
|
||||||
|
(tmp_path / name).mkdir()
|
||||||
|
store = IntegratedPerceptionOverlayStore(
|
||||||
|
jobs_root=tmp_path / "jobs",
|
||||||
|
results_root=tmp_path / "results",
|
||||||
|
lidar_packs_root=tmp_path / "lidar-packs",
|
||||||
|
cache_root=tmp_path / "cache",
|
||||||
|
ffmpeg_path=tmp_path / "ffmpeg",
|
||||||
|
)
|
||||||
|
result_id = f"e10-integrated-perception-{'a' * 64}"
|
||||||
|
result = SimpleNamespace(
|
||||||
|
result_id=result_id,
|
||||||
|
created_at_utc="2026-07-29T12:00:00Z",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(store, "_read_admitted_cache", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(store, "_latest", lambda _session_id: result)
|
||||||
|
monkeypatch.setattr(store, "_write_admission", lambda _result: None)
|
||||||
|
|
||||||
|
active = 0
|
||||||
|
maximum_active = 0
|
||||||
|
active_lock = threading.Lock()
|
||||||
|
|
||||||
|
def render_overlay(*_args: object, **_kwargs: object) -> bytes:
|
||||||
|
nonlocal active, maximum_active
|
||||||
|
with active_lock:
|
||||||
|
active += 1
|
||||||
|
maximum_active = max(maximum_active, active)
|
||||||
|
time.sleep(0.05)
|
||||||
|
with active_lock:
|
||||||
|
active -= 1
|
||||||
|
return b"RRF2materialized"
|
||||||
|
|
||||||
|
monkeypatch.setattr(integrated_module, "_render", render_overlay)
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
results = tuple(
|
||||||
|
executor.map(
|
||||||
|
lambda item: store.render(
|
||||||
|
item,
|
||||||
|
application_id="nodedc_mission_core_recorded",
|
||||||
|
recording_id=f"recording-{item}",
|
||||||
|
),
|
||||||
|
("session-1", "session-2"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == (b"RRF2materialized", b"RRF2materialized")
|
||||||
|
assert maximum_active == 1
|
||||||
|
|
||||||
|
|
||||||
def test_e10_profile_pins_integrated_realtime_budget() -> None:
|
def test_e10_profile_pins_integrated_realtime_budget() -> None:
|
||||||
|
|||||||
@@ -683,6 +683,10 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa
|
|||||||
assert summary["first_decoded_time_ns"] == 600_000_000
|
assert summary["first_decoded_time_ns"] == 600_000_000
|
||||||
assert summary["last_decoded_time_ns"] == 1_700_000_000
|
assert summary["last_decoded_time_ns"] == 1_700_000_000
|
||||||
assert summary["source_sha256"] == _sha256(capture)
|
assert summary["source_sha256"] == _sha256(capture)
|
||||||
|
repeated = export_k1mqtt_to_rrd(capture, tmp_path / "session-repeated.rrd")
|
||||||
|
assert repeated["recording_id"] == summary["recording_id"]
|
||||||
|
assert summary["recording_id"].startswith("k1-")
|
||||||
|
assert len(summary["recording_id"]) == 67
|
||||||
assert summary["rrd_bytes"] == output.stat().st_size
|
assert summary["rrd_bytes"] == output.stat().st_size
|
||||||
assert summary["rrd_sha256"] == _sha256(output)
|
assert summary["rrd_sha256"] == _sha256(output)
|
||||||
assert output.stat().st_size > 0
|
assert output.stat().st_size > 0
|
||||||
|
|||||||
@@ -1313,6 +1313,16 @@ def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
|
|||||||
calls.append((session_id, application_id, recording_id))
|
calls.append((session_id, application_id, recording_id))
|
||||||
return b"RRF2perception"
|
return b"RRF2perception"
|
||||||
|
|
||||||
|
def status(self, session_id: str, *, recording_id: str) -> dict[str, object]:
|
||||||
|
assert session_id == session.name
|
||||||
|
assert recording_id == "recording-001"
|
||||||
|
return {
|
||||||
|
"state": "preparing",
|
||||||
|
"phase": "artifact-validation",
|
||||||
|
"elapsed_seconds": 2.5,
|
||||||
|
"byte_length": None,
|
||||||
|
}
|
||||||
|
|
||||||
router = build_session_router(store, perception_overlay_provider=Provider())
|
router = build_session_router(store, perception_overlay_provider=Provider())
|
||||||
perception_route = endpoint(
|
perception_route = endpoint(
|
||||||
router,
|
router,
|
||||||
@@ -1334,6 +1344,26 @@ def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
|
|||||||
assert calls == [
|
assert calls == [
|
||||||
(session.name, "nodedc_mission_core_recorded", "recording-001")
|
(session.name, "nodedc_mission_core_recorded", "recording-001")
|
||||||
]
|
]
|
||||||
|
status_route = endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/observation-sessions/{session_id}/perception/status",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
status_response = Response()
|
||||||
|
status = asyncio.run(
|
||||||
|
status_route(
|
||||||
|
session_id=session.name,
|
||||||
|
response=status_response,
|
||||||
|
recording_id="recording-001",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert status == {
|
||||||
|
"state": "preparing",
|
||||||
|
"phase": "artifact-validation",
|
||||||
|
"elapsed_seconds": 2.5,
|
||||||
|
"byte_length": None,
|
||||||
|
}
|
||||||
|
assert status_response.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
empty_router = build_session_router(store)
|
empty_router = build_session_router(store)
|
||||||
empty_route = endpoint(
|
empty_route = endpoint(
|
||||||
|
|||||||
Reference in New Issue
Block a user