fix(replay): recover failed archives and release deleted sessions

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 01:55:23 +03:00
parent 2a97cf28c0
commit 1eba7c82ba
8 changed files with 217 additions and 7 deletions
+24
View File
@@ -199,6 +199,7 @@ export default function App() {
const livePerceptionLayersRef = useRef<LivePerceptionLayers>(defaultLivePerceptionLayers); const livePerceptionLayersRef = useRef<LivePerceptionLayers>(defaultLivePerceptionLayers);
const livePerceptionRevisionRef = useRef(0); const livePerceptionRevisionRef = useRef(0);
const replayActiveRef = useRef(false); const replayActiveRef = useRef(false);
const selectedRecordedSessionIdRef = useRef<string | null>(null);
const sceneSettingsCommitterActiveRef = useRef(true); const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null); const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const polygonDatasetRouteOpenedRef = useRef(false); const polygonDatasetRouteOpenedRef = useRef(false);
@@ -536,6 +537,7 @@ export default function App() {
// produced a validated launch descriptor. Keep the old scene mounted // produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before // before this point; now perform one controlled receiver teardown before
// accepting the already-ready archive. // accepting the already-ready archive.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(true); setReplayTransitioning(true);
setRecordedReplay(null); setRecordedReplay(null);
setRecordedReplayLabel(null); setRecordedReplayLabel(null);
@@ -553,6 +555,7 @@ export default function App() {
if (sourceSwitchBlockedRef.current) { if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON); throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
} }
selectedRecordedSessionIdRef.current = session.id;
setRecordedReplay(launch); setRecordedReplay(launch);
setRecordedReplayLabel(session.label); setRecordedReplayLabel(session.label);
setSourceUrl(launch.sourceUrl); setSourceUrl(launch.sourceUrl);
@@ -564,11 +567,28 @@ export default function App() {
if (outcome !== "accepted") setReplayTransitioning(false); if (outcome !== "accepted") setReplayTransitioning(false);
}, []); }, []);
const releaseRecordedReplayForDelete = useCallback(async (sessionId: string) => {
if (selectedRecordedSessionIdRef.current !== sessionId) return;
// Deleting the selected archive is a source transition. Unmount Rerun and
// recorded media first so no admission request can outlive the evidence.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(true);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
});
if (selectedRecordedSessionIdRef.current === null) setReplayTransitioning(false);
}, []);
const activateAutomaticSpatialSource = useCallback(() => { const activateAutomaticSpatialSource = useCallback(() => {
// A plugin-owned live/file-replay start must release any host-selected // A plugin-owned live/file-replay start must release any host-selected
// archive or manual URL before the new acquisition becomes non-terminal. // archive or manual URL before the new acquisition becomes non-terminal.
// This transition is an internal start boundary, not an operator source // This transition is an internal start boundary, not an operator source
// switch, so it intentionally does not consult the acquisition guard. // switch, so it intentionally does not consult the acquisition guard.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(false); setReplayTransitioning(false);
setRecordedReplay(null); setRecordedReplay(null);
setRecordedReplayLabel(null); setRecordedReplayLabel(null);
@@ -786,6 +806,7 @@ export default function App() {
onReplayAccepted={acceptRecordedReplay} onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) => onReplaySettled={(_session, outcome) =>
settleRecordedReplaySwitch(outcome)} settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete}
/> />
) : activeDefinition.kind === "datasets" ? ( ) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge> <StatusBadge tone="neutral">Offline evaluation</StatusBadge>
@@ -838,6 +859,7 @@ export default function App() {
onReplayAccepted: acceptRecordedReplay, onReplayAccepted: acceptRecordedReplay,
onReplaySettled: (_session, outcome) => onReplaySettled: (_session, outcome) =>
settleRecordedReplaySwitch(outcome), settleRecordedReplaySwitch(outcome),
onDeleteBegin: releaseRecordedReplayForDelete,
}} }}
navigation={{ navigation={{
openView, openView,
@@ -886,6 +908,7 @@ export default function App() {
title={sourceSwitchBlockedReason ?? undefined} title={sourceSwitchBlockedReason ?? undefined}
onClick={() => { onClick={() => {
if (sourceSwitchBlockedRef.current) return; if (sourceSwitchBlockedRef.current) return;
selectedRecordedSessionIdRef.current = null;
setSourceDraft(""); setSourceDraft("");
setSourceUrl(""); setSourceUrl("");
setRecordedReplay(null); setRecordedReplay(null);
@@ -901,6 +924,7 @@ export default function App() {
title={sourceSwitchBlockedReason ?? undefined} title={sourceSwitchBlockedReason ?? undefined}
onClick={() => { onClick={() => {
if (sourceSwitchBlockedRef.current) return; if (sourceSwitchBlockedRef.current) return;
selectedRecordedSessionIdRef.current = null;
setSourceUrl(sourceDraft.trim()); setSourceUrl(sourceDraft.trim());
setRecordedReplay(null); setRecordedReplay(null);
setRecordedReplayLabel(null); setRecordedReplayLabel(null);
@@ -105,6 +105,7 @@ export interface ObservationSessionReplayCallbacks {
session: ObservationSessionSummary, session: ObservationSessionSummary,
outcome: ObservationReplayOutcome, outcome: ObservationReplayOutcome,
) => void | Promise<void>; ) => void | Promise<void>;
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
} }
export function ObservationSessionSelect({ export function ObservationSessionSelect({
@@ -114,6 +115,7 @@ export function ObservationSessionSelect({
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & { }: ObservationSessionReplayCallbacks & {
limit?: number; limit?: number;
disabled?: boolean; disabled?: boolean;
@@ -127,6 +129,7 @@ export function ObservationSessionSelect({
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
onDeleteBegin,
}); });
const triggerCopy = sessions.replayProgress const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase) ? progressCopy(sessions.replayProgress.phase)
@@ -313,6 +316,7 @@ export function ObservationSessionArchive({
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & { }: ObservationSessionReplayCallbacks & {
limit?: number; limit?: number;
labsOnly?: boolean; labsOnly?: boolean;
@@ -327,6 +331,7 @@ export function ObservationSessionArchive({
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
onDeleteBegin,
}); });
const items = sessions.items; const items = sessions.items;
@@ -52,6 +52,20 @@ export interface ObservationReplayCoordinator {
cancel: () => void; cancel: () => void;
} }
export async function deleteObservationSessionAfterTeardown(
sessionId: string,
{
onDeleteBegin,
deleteSession = deleteObservationSession,
}: {
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
deleteSession?: (sessionId: string) => Promise<void>;
} = {},
): Promise<void> {
await onDeleteBegin?.(sessionId);
await deleteSession(sessionId);
}
export interface ObservationPreparationPollingOptions { export interface ObservationPreparationPollingOptions {
signal: AbortSignal; signal: AbortSignal;
fetcher?: ObservationSessionFetch; fetcher?: ObservationSessionFetch;
@@ -375,6 +389,7 @@ export function useObservationSessions({
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
onDeleteBegin,
}: { }: {
limit?: number; limit?: number;
scope?: ObservationSessionScope; scope?: ObservationSessionScope;
@@ -392,6 +407,8 @@ export function useObservationSessions({
session: ObservationSessionSummary, session: ObservationSessionSummary,
outcome: ObservationReplayOutcome, outcome: ObservationReplayOutcome,
) => void | Promise<void>; ) => void | Promise<void>;
/** Releases an active viewer before its evidence and derived cache are deleted. */
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
} = {}): ObservationSessionsController { } = {}): ObservationSessionsController {
const [items, setItems] = useState<ObservationSessionSummary[]>([]); const [items, setItems] = useState<ObservationSessionSummary[]>([]);
const [state, setState] = useState<ObservationSessionsLoadState>("idle"); const [state, setState] = useState<ObservationSessionsLoadState>("idle");
@@ -606,7 +623,7 @@ export function useObservationSessions({
setDeletingSessionId(sessionId); setDeletingSessionId(sessionId);
setError(null); setError(null);
try { try {
await deleteObservationSession(sessionId); await deleteObservationSessionAfterTeardown(sessionId, { onDeleteBegin });
if (!mounted.current) return false; if (!mounted.current) return false;
setItems((current) => current.filter((item) => item.id !== sessionId)); setItems((current) => current.filter((item) => item.id !== sessionId));
setFailedSessionId((current) => current === sessionId ? null : current); setFailedSessionId((current) => current === sessionId ? null : current);
@@ -617,7 +634,7 @@ export function useObservationSessions({
} finally { } finally {
if (mounted.current) setDeletingSessionId(null); if (mounted.current) setDeletingSessionId(null);
} }
}, [deletingSessionId, replayingSessionId]); }, [deletingSessionId, onDeleteBegin, replayingSessionId]);
return { return {
items, items,
@@ -583,6 +583,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
onReplayBegin: props.sessionArchive.onReplayBegin, onReplayBegin: props.sessionArchive.onReplayBegin,
onReplayAccepted: props.sessionArchive.onReplayAccepted, onReplayAccepted: props.sessionArchive.onReplayAccepted,
onReplaySettled: props.sessionArchive.onReplaySettled, onReplaySettled: props.sessionArchive.onReplaySettled,
onDeleteBegin: props.sessionArchive.onDeleteBegin,
}); });
const publishedWorks = useMemo( const publishedWorks = useMemo(
() => sessions.items.filter((session) => ( () => sessions.items.filter((session) => (
@@ -17,6 +17,7 @@ let ObservationSessionApiError;
let ObservationSessionContractError; let ObservationSessionContractError;
let decodeObservationRecordedMediaManifest; let decodeObservationRecordedMediaManifest;
let createObservationReplayCoordinator; let createObservationReplayCoordinator;
let deleteObservationSessionAfterTeardown;
let resolveObservationSessionReplay; let resolveObservationSessionReplay;
let waitForObservationReplayPreparation; let waitForObservationReplayPreparation;
let storeObservationReplayPreparation; let storeObservationReplayPreparation;
@@ -45,6 +46,7 @@ before(async () => {
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts")); } = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
({ ({
createObservationReplayCoordinator, createObservationReplayCoordinator,
deleteObservationSessionAfterTeardown,
resolveObservationSessionReplay, resolveObservationSessionReplay,
waitForObservationReplayPreparation, waitForObservationReplayPreparation,
storeObservationReplayPreparation, storeObservationReplayPreparation,
@@ -437,6 +439,56 @@ test("delete API uses one opaque same-origin target and requires an empty 204",
); );
}); });
test("session deletion releases the selected presentation before the DELETE request", async () => {
const events = [];
await deleteObservationSessionAfterTeardown("session-20260716T205632Z", {
onDeleteBegin: async (sessionId) => {
events.push(`release:${sessionId}`);
},
deleteSession: async (sessionId) => {
events.push(`delete:${sessionId}`);
},
});
assert.deepEqual(events, [
"release:session-20260716T205632Z",
"delete:session-20260716T205632Z",
]);
let deleteCalled = false;
await assert.rejects(
deleteObservationSessionAfterTeardown("session-20260716T205632Z", {
onDeleteBegin: async () => {
throw new Error("viewer teardown failed");
},
deleteSession: async () => {
deleteCalled = true;
},
}),
/viewer teardown failed/,
);
assert.equal(deleteCalled, false);
});
test("App atomically clears the selected Rerun source before deleting its session", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
assert.match(
appSource,
/selectedRecordedSessionIdRef\.current = session\.id[\s\S]*setRecordedReplay\(launch\)/,
);
assert.match(
appSource,
/releaseRecordedReplayForDelete[\s\S]*selectedRecordedSessionIdRef\.current !== sessionId[\s\S]*selectedRecordedSessionIdRef\.current = null[\s\S]*setRecordedReplay\(null\)[\s\S]*setSourceUrl\(""\)/,
);
assert.match(
appSource,
/onDeleteBegin=\{releaseRecordedReplayForDelete\}/,
);
assert.match(
appSource,
/onDeleteBegin:\s*releaseRecordedReplayForDelete/,
);
});
test("replay API accepts only a same-origin seekable recording descriptor", async () => { test("replay API accepts only a same-origin seekable recording descriptor", async () => {
const calls = []; const calls = [];
const launch = await replayObservationSession("session-20260716T205632Z", { const launch = await replayObservationSession("session-20260716T205632Z", {
+15
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import queue import queue
import threading import threading
@@ -20,6 +21,8 @@ from .recording import (
SessionRecordingMaterializer, SessionRecordingMaterializer,
) )
LOGGER = logging.getLogger(__name__)
PreparationState = Literal[ PreparationState = Literal[
"queued", "queued",
"validating", "validating",
@@ -582,6 +585,12 @@ class SessionRecordingPreparationManager:
except Exception: except Exception:
# The public status intentionally does not expose paths, # The public status intentionally does not expose paths,
# broker payloads or exporter internals. # broker payloads or exporter internals.
LOGGER.exception(
"recording preparation materialization failed: "
"session_id=%s preparation_id=%s",
job.command.session_id,
job.preparation_id,
)
with self._guard: with self._guard:
if job.cancel_event.is_set(): if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress) self._transition_locked(job, "cancelled", job.progress)
@@ -616,6 +625,12 @@ class SessionRecordingPreparationManager:
recording_end_seconds=(recording.timeline_end_ns / 1_000_000_000), recording_end_seconds=(recording.timeline_end_ns / 1_000_000_000),
) )
except Exception: except Exception:
LOGGER.exception(
"recording preparation finalization failed: "
"session_id=%s preparation_id=%s",
job.command.session_id,
job.preparation_id,
)
with self._guard: with self._guard:
if job.cancel_event.is_set(): if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress) self._transition_locked(job, "cancelled", job.progress)
+14 -5
View File
@@ -372,8 +372,10 @@ def build_session_router(
try: try:
store.get_session(session_id) store.get_session(session_id)
recorded_artifacts = store.list_recorded_media(session_id) recorded_artifacts = store.list_recorded_media(session_id)
except SessionNotFoundError as exc: except SessionNotFoundError:
raise HTTPException(status_code=404, detail=str(exc)) from exc # DELETE is idempotent. Multiple mounted catalogs or another
# operator tab may have already removed the exact session.
return Response(status_code=204)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
@@ -427,8 +429,10 @@ def build_session_router(
(artifact.artifact_id for artifact in recorded_artifacts), (artifact.artifact_id for artifact in recorded_artifacts),
) )
await run_in_threadpool(store.delete_session, session_id) await run_in_threadpool(store.delete_session, session_id)
except SessionNotFoundError as exc: except SessionNotFoundError:
raise HTTPException(status_code=404, detail=str(exc)) from exc # A concurrent idempotent delete won the race after both callers
# resolved the catalog row.
return Response(status_code=204)
except (OSError, SessionIntegrityError) as exc: except (OSError, SessionIntegrityError) as exc:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
@@ -469,7 +473,12 @@ def build_session_router(
recording_preparation_manager.restore_published, recording_preparation_manager.restore_published,
command, command,
) )
if snapshot is None: if snapshot is None or snapshot.state in {"failed", "cancelled"}:
# A terminal preparation describes one attempt, not the
# durable session. A fresh POST /replay is explicit retry
# intent and must replace that attempt. This also recovers
# the camera-finalization race where the RRD is already
# published but the media archive seals moments later.
snapshot = recording_preparation_manager.enqueue( snapshot = recording_preparation_manager.enqueue(
command, command,
retry_failed=True, retry_failed=True,
+87
View File
@@ -22,6 +22,7 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.sessions import ( from k1link.sessions import (
MaterializedRecording, MaterializedRecording,
RecordedMediaInspector, RecordedMediaInspector,
RecordedMediaManifest,
ReplayCommand, ReplayCommand,
SessionIntegrityError, SessionIntegrityError,
SessionNotFoundError, SessionNotFoundError,
@@ -362,8 +363,10 @@ def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording
release() release()
response = asyncio.run(delete_route(session_id=session.name)) response = asyncio.run(delete_route(session_id=session.name))
repeated_response = asyncio.run(delete_route(session_id=session.name))
assert response.status_code == 204 assert response.status_code == 204
assert repeated_response.status_code == 204
assert not session.exists() assert not session.exists()
assert not recording.path.parent.exists() assert not recording.path.parent.exists()
with pytest.raises(SessionNotFoundError): with pytest.raises(SessionNotFoundError):
@@ -763,6 +766,90 @@ def test_cold_replay_returns_quick_202_then_status_returns_ready_launch(
manager.close() manager.close()
def test_replay_post_retries_terminal_camera_finalization_failure(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
payload = b"recording-ready-before-camera-archive"
export_calls = 0
finalization_calls = 0
def export_recording(source: Path, destination: Path) -> dict[str, object]:
nonlocal export_calls
export_calls += 1
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 2_500_000_000,
}
def prepare_recorded_media(
_command: ReplayCommand,
_recording: MaterializedRecording,
) -> tuple[RecordedMediaManifest, ...]:
nonlocal finalization_calls
finalization_calls += 1
if finalization_calls == 1:
raise SessionIntegrityError("camera archive is still sealing")
return ()
materializer = SessionRecordingMaterializer(
store.data_dir,
exporter=export_recording,
)
manager = SessionRecordingPreparationManager(
materializer,
ready_preparer=prepare_recorded_media,
)
router = build_session_router(
store,
recording_materializer=materializer,
recording_preparation_manager=manager,
)
replay_route = endpoint(
router,
"/api/v1/observation-sessions/{session_id}/replay",
"POST",
)
try:
first = asyncio.run(replay_route(session_id=session.name, request=None))
assert first.status_code == 202
deadline = time.monotonic() + 2
failed = manager.status(session.name)
while failed is None or failed.state != "failed":
assert time.monotonic() < deadline
time.sleep(0.005)
failed = manager.status(session.name)
failed_preparation_id = failed.preparation_id
retry = asyncio.run(replay_route(session_id=session.name, request=None))
assert retry.status_code == 202
retry_document = json.loads(retry.body)
assert retry_document["preparation"]["preparation_id"] != failed_preparation_id
deadline = time.monotonic() + 2
ready = manager.status(session.name)
while ready is None or ready.state != "ready":
assert time.monotonic() < deadline
time.sleep(0.005)
ready = manager.status(session.name)
launch = asyncio.run(replay_route(session_id=session.name, request=None))
assert launch["launch"]["session_id"] == session.name
assert export_calls == 1
assert finalization_calls == 2
finally:
manager.close()
def test_catalog_read_never_prepares_a_cold_historical_session(tmp_path: Path) -> None: def test_catalog_read_never_prepares_a_cold_historical_session(tmp_path: Path) -> None:
repository = tmp_path / "repo" repository = tmp_path / "repo"
sessions = repository / "sessions" sessions = repository / "sessions"