fix(archive): preserve prepared sessions across restarts

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 11:08:27 +03:00
parent aa3680948f
commit 574a494759
13 changed files with 398 additions and 132 deletions
+36
View File
@@ -175,6 +175,42 @@ class RecordedMediaInspector:
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
return manifest
def restore_prepared(
self,
artifact: RecordedMediaArtifact,
replay: ReplayCommand,
) -> RecordedMediaManifest | None:
"""Restore one previously prepared manifest without rebuilding it.
A catalog read or process restart must never turn an old camera archive
into fresh preparation work. The durable sidecar is accepted only when
its complete source stat identity still matches the sealed archive.
Missing or stale sidecars remain cold until the operator explicitly
requests that session.
"""
if artifact.session_id != replay.session_id:
raise SessionIntegrityError("recorded media does not belong to replay session")
epoch_paths = _epoch_paths(artifact.source_path)
key = (artifact.session_id, artifact.artifact_id)
with self._lock:
cached = self._cache.get(key)
if cached is not None:
identity = _prepared_source_identity(
replay,
epoch_paths,
cached.manifest.epochs,
)
if cached.identity == identity:
return cached.manifest
prepared = self._load_prepared_sidecar(artifact, replay, epoch_paths)
if prepared is None:
return None
manifest, identity = prepared
with self._lock:
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
return manifest
def _load_prepared_sidecar(
self,
artifact: RecordedMediaArtifact,
+76 -7
View File
@@ -111,6 +111,11 @@ class SessionRecordingPreparationManager:
tuple[RecordedMediaManifest, ...],
]
| None = None,
ready_restorer: Callable[
[ReplayCommand, MaterializedRecording],
tuple[RecordedMediaManifest, ...] | None,
]
| None = None,
) -> None:
if queue_capacity < 1:
raise ValueError("recording preparation queue capacity must be positive")
@@ -123,6 +128,7 @@ class SessionRecordingPreparationManager:
# hung exporter must become observable to the browser stall detector.
self._heartbeat_interval_seconds = heartbeat_interval_seconds
self._ready_preparer = ready_preparer
self._ready_restorer = ready_restorer
self._guard = threading.RLock()
self._current_by_session: dict[str, _PreparationJob] = {}
self._closed = True
@@ -237,6 +243,74 @@ class SessionRecordingPreparationManager:
self._current_by_session[command.session_id] = ready
return self._snapshot_locked(ready)
def restore_published(
self,
command: ReplayCommand,
) -> RecordingPreparationSnapshot | None:
"""Restore a complete durable package without enqueueing preparation.
The RRD and every recorded-media descriptor must already have been
published by an earlier successful job. Missing/stale pieces return a
cold miss; this method never invokes an exporter or media parser.
"""
source_command = _source_command(command)
identity = _source_identity(source_command)
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is not None
and current.source_identity == identity
and current.state in ACTIVE_PREPARATION_STATES | {"ready"}
):
return self._snapshot_locked(current)
recording = self.materializer.restore_published(source_command)
if recording is None:
with self._guard:
current = self._current_by_session.get(command.session_id)
if current is not None and current.state == "ready":
self._current_by_session.pop(command.session_id, None)
return None
if self._ready_preparer is None:
recorded_media: tuple[RecordedMediaManifest, ...] = ()
else:
if self._ready_restorer is None:
return None
restored_media = self._ready_restorer(source_command, recording)
if restored_media is None:
return None
recorded_media = restored_media
validate_recorded_media_timeline(
recorded_media,
recording_start_seconds=recording.timeline_start_ns / 1_000_000_000,
recording_end_seconds=recording.timeline_end_ns / 1_000_000_000,
)
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is not None
and current.source_identity == identity
and current.state in ACTIVE_PREPARATION_STATES | {"ready"}
):
if current.state == "ready":
current.recording = recording
current.recorded_media = recorded_media
return self._snapshot_locked(current)
ready = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
state="ready",
progress=1.0,
recording=recording,
recorded_media=recorded_media,
)
self._current_by_session[command.session_id] = ready
return self._snapshot_locked(ready)
def resolve_cached_pinned(
self,
command: ReplayCommand,
@@ -325,10 +399,7 @@ class SessionRecordingPreparationManager:
job is None
or job.state != "ready"
or job.recording is None
or (
preparation_id is not None
and job.preparation_id != preparation_id
)
or (preparation_id is not None and job.preparation_id != preparation_id)
):
return None
recording = job.recording
@@ -456,9 +527,7 @@ class SessionRecordingPreparationManager:
recording_start_seconds=(
recording.timeline_start_ns / 1_000_000_000
),
recording_end_seconds=(
recording.timeline_end_ns / 1_000_000_000
),
recording_end_seconds=(recording.timeline_end_ns / 1_000_000_000),
)
except Exception:
with self._guard:
+40 -8
View File
@@ -232,6 +232,26 @@ class SessionRecordingMaterializer:
with self._lock_for(session_id):
return self._load_cached_recording(session_id, _validate_source(command))
def restore_published(self, command: ReplayCommand) -> MaterializedRecording | None:
"""Restore an immutable published cache using bounded metadata checks.
Full source/output digests are proved before the cache is published.
On a later process start the private cache is restored only when its
schema, source stat identities and derived-file stat identity still
match the durable sidecar. This keeps a prepared recording durable
across restarts without rereading gigabytes or starting an exporter.
"""
session_id = _validate_command_shape(command)
if not self._has_compatible_cache_candidate(session_id):
return None
with self._lock_for(session_id):
return self._load_cached_recording(
session_id,
_validate_source(command),
verify_digests=False,
)
def get_cached_pinned(
self,
command: ReplayCommand,
@@ -430,6 +450,7 @@ class SessionRecordingMaterializer:
source: _ValidatedSource,
*,
pin: bool = False,
verify_digests: bool = True,
) -> MaterializedRecording | None:
# Cache validation and the optional lease are one transaction with
# eviction. This makes it safe for FileResponse to open the path after
@@ -451,6 +472,7 @@ class SessionRecordingMaterializer:
source=source,
recording_path=recording_path,
sidecar_path=sidecar_path,
verify_digests=verify_digests,
)
if cached is None:
return None
@@ -661,6 +683,7 @@ class SessionRecordingMaterializer:
source: _ValidatedSource,
recording_path: Path,
sidecar_path: Path,
verify_digests: bool = True,
) -> MaterializedRecording | None:
if recording_path.is_symlink() or sidecar_path.is_symlink():
return None
@@ -698,12 +721,19 @@ class SessionRecordingMaterializer:
source_sha256: str | None = None
for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True):
digest = _sha256_prefix_stable(
artifact.path,
artifact.file_stat,
artifact.replay_byte_length,
digest = (
_sha256_prefix_stable(
artifact.path,
artifact.file_stat,
artifact.replay_byte_length,
)
if verify_digests
else cached["sha256"]
)
if artifact.expected_sha256 is not None and digest != artifact.expected_sha256:
if (
artifact.expected_sha256 is not None
and cached["sha256"] != artifact.expected_sha256
):
raise RecordingMaterializationError(
"recording artifact digest no longer matches catalog"
)
@@ -713,9 +743,11 @@ class SessionRecordingMaterializer:
source_sha256 = digest
if source_sha256 is None or source_sha256 != document["source_sha256"]:
return None
recording_sha256 = _sha256_stable(recording_path, recording_stat)
if recording_sha256 != document["recording_sha256"]:
return None
recording_sha256 = document["recording_sha256"]
if verify_digests:
recording_sha256 = _sha256_stable(recording_path, recording_stat)
if recording_sha256 != document["recording_sha256"]:
return None
_chmod_best_effort(recording_path, 0o600)
_chmod_best_effort(sidecar_path, 0o600)
if document["schema_version"] != CACHE_SCHEMA: