feat(storage): add portable session artifact gateway
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user