feat(storage): add portable session artifact gateway

This commit is contained in:
DCCONSTRUCTIONS
2026-07-30 10:05:48 +03:00
parent 2ab9e45548
commit e56fa0c074
11 changed files with 1889 additions and 2 deletions
+117 -2
View File
@@ -16,6 +16,13 @@ from pathlib import Path
from typing import Any, cast
from uuid import uuid4
from k1link.artifact_gateway import (
ArtifactGateway,
ArtifactGatewayError,
ArtifactNotFound,
ArtifactStoreUnavailable,
)
from .models import ReplayArtifact, ReplayCommand
from .plugin_contract import (
PluginRecordingExportCancelled,
@@ -139,6 +146,7 @@ class SessionRecordingMaterializer:
exporters: Mapping[str, RecordingExporter] | None = None,
cache_max_bytes: int | None = None,
free_space_reserve_bytes: int | None = None,
artifact_gateway: ArtifactGateway | None = None,
) -> None:
private_root = data_dir.expanduser().resolve()
private_root.mkdir(mode=0o700, parents=True, exist_ok=True)
@@ -166,6 +174,7 @@ class SessionRecordingMaterializer:
environment_name="MISSIONCORE_RRD_FREE_SPACE_RESERVE_BYTES",
default=DEFAULT_FREE_SPACE_RESERVE_BYTES,
)
self.artifact_gateway = artifact_gateway
self._locks_guard = threading.Lock()
self._session_locks: dict[str, threading.Lock] = {}
self._memory_guard = threading.Lock()
@@ -349,6 +358,11 @@ class SessionRecordingMaterializer:
if cached is not None:
_report_progress(progress_callback, "ready", 1.0)
return cached
_report_progress(progress_callback, "restoring", 0.15)
restored = self._restore_gateway_recording_locked(session_id, source)
if restored is not None:
_report_progress(progress_callback, "ready", 1.0)
return restored
_report_progress(progress_callback, "exporting", 0.2)
return self._export_recording_locked(
session_id,
@@ -373,13 +387,114 @@ class SessionRecordingMaterializer:
self._scavenge_export_artifacts_locked()
source = _validate_source(command)
recording = self._load_cached_recording(session_id, source, pin=True)
if recording is None:
recording = self._restore_gateway_recording_locked(session_id, source)
if recording is None:
recording = self._export_recording_locked(session_id, source)
with self._cache_guard:
self._increment_pin_locked(session_id)
with self._cache_guard:
self._increment_pin_locked(session_id)
return recording, self._release_callback(session_id)
def _restore_gateway_recording_locked(
self,
session_id: str,
source: _ValidatedSource,
) -> MaterializedRecording | None:
if self.artifact_gateway is None:
return None
try:
resolved = self.artifact_gateway.resolve_role(
"sessions",
session_id,
"base-rrd",
)
except (ArtifactNotFound, ArtifactStoreUnavailable):
return None
except ArtifactGatewayError as exc:
raise RecordingMaterializationError(
"central recording artifact failed validation"
) from exc
metadata = resolved.manifest.metadata
if (
resolved.manifest.artifact_type != "recorded-session"
or resolved.manifest.subject_id != session_id
or resolved.member.media_type != RERUN_RECORDING_MEDIA_TYPE
or metadata.get("base-timeline") != RERUN_SESSION_TIMELINE
):
raise RecordingMaterializationError("central recording manifest is invalid")
source_sha256 = metadata.get("base-source-sha256")
try:
timeline_start_ns = int(metadata["base-timeline-start-ns"])
timeline_end_ns = int(metadata["base-timeline-end-ns"])
except (KeyError, TypeError, ValueError) as exc:
raise RecordingMaterializationError(
"central recording timeline metadata is invalid"
) from exc
artifact_digests = _validated_artifact_digests(source)
if (
not isinstance(source_sha256, str)
or source_sha256 != artifact_digests[source.primary_artifact_id]
or timeline_start_ns < 0
or timeline_end_ns < timeline_start_ns
):
raise RecordingMaterializationError(
"central recording does not match the local source evidence"
)
with self._cache_guard:
session_root, recording_path, sidecar_path = self._cache_paths(session_id)
self._ensure_cache_capacity(
required_bytes=resolved.member.byte_length + 4 * 1024,
protected_session_id=session_id,
)
candidate = session_root / f".scene.{uuid4().hex}.candidate.rrd"
try:
digest = hashlib.sha256()
byte_length = 0
with resolved.path.open("rb") as source_stream, candidate.open("xb") as output:
while chunk := source_stream.read(1024 * 1024):
output.write(chunk)
digest.update(chunk)
byte_length += len(chunk)
output.flush()
os.fsync(output.fileno())
if (
digest.hexdigest() != resolved.member.sha256
or byte_length != resolved.member.byte_length
):
raise RecordingMaterializationError(
"central recording changed during local restoration"
)
_chmod_best_effort(candidate, 0o600)
os.replace(candidate, recording_path)
_fsync_directory(session_root)
recording_stat = _regular_file_stat(recording_path, "derived recording")
recording = MaterializedRecording(
session_id=session_id,
path=recording_path,
media_type=RERUN_RECORDING_MEDIA_TYPE,
byte_length=byte_length,
sha256=resolved.member.sha256,
source_sha256=source_sha256,
timeline=RERUN_SESSION_TIMELINE,
timeline_start_ns=timeline_start_ns,
timeline_end_ns=timeline_end_ns,
)
_write_json_atomic(
sidecar_path,
_cache_document(recording, source, recording_stat),
)
_chmod_best_effort(sidecar_path, 0o600)
self._remember(recording, source, recording_stat)
_touch_lru(session_root)
return recording
except OSError as exc:
raise RecordingMaterializationError(
"central recording could not be restored locally"
) from exc
finally:
candidate.unlink(missing_ok=True)
def _release_callback(self, session_id: str) -> Callable[[], None]:
released = False
release_guard = threading.Lock()