fix(archive): preserve prepared sessions across restarts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user