feat(sessions): add durable observation archive and replay API
This commit is contained in:
parent
aa2df560b7
commit
656f0c524d
|
|
@ -26,6 +26,9 @@ private/
|
||||||
# Real laboratory captures and decoded artifacts are sensitive and large.
|
# Real laboratory captures and decoded artifacts are sensitive and large.
|
||||||
captures/
|
captures/
|
||||||
sessions/
|
sessions/
|
||||||
|
# The host runtime package shares the domain name but is source, not evidence.
|
||||||
|
!src/k1link/sessions/
|
||||||
|
!src/k1link/sessions/**/*.py
|
||||||
artifacts/raw/
|
artifacts/raw/
|
||||||
artifacts/decoded/
|
artifacts/decoded/
|
||||||
*.pcap
|
*.pcap
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ MAX_TOPIC_BYTES = 65_535
|
||||||
CONNECT_TIMEOUT_SECONDS = 10.0
|
CONNECT_TIMEOUT_SECONDS = 10.0
|
||||||
KEEPALIVE_SECONDS = 30
|
KEEPALIVE_SECONDS = 30
|
||||||
LOOP_INTERVAL_SECONDS = 0.25
|
LOOP_INTERVAL_SECONDS = 0.25
|
||||||
|
GROUP_COMMIT_INTERVAL_SECONDS = 0.5
|
||||||
|
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
|
||||||
|
GROUP_COMMIT_MAX_MESSAGES = 32
|
||||||
|
|
||||||
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
|
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
|
||||||
RAW_MAGIC = b"K1MQTT\x00\x01"
|
RAW_MAGIC = b"K1MQTT\x00\x01"
|
||||||
|
|
@ -166,6 +169,9 @@ class _CaptureWriter:
|
||||||
self.topic_counts: dict[str, int] = {}
|
self.topic_counts: dict[str, int] = {}
|
||||||
self._raw: IO[bytes] | None = None
|
self._raw: IO[bytes] | None = None
|
||||||
self._metadata: IO[str] | None = None
|
self._metadata: IO[str] | None = None
|
||||||
|
self._pending_metadata: list[str] = []
|
||||||
|
self._pending_raw_bytes = 0
|
||||||
|
self._last_commit_monotonic = time.monotonic()
|
||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
self.out_dir.mkdir(parents=True, exist_ok=True)
|
self.out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -179,6 +185,7 @@ class _CaptureWriter:
|
||||||
self._raw = _open_binary_exclusive(self.raw_path)
|
self._raw = _open_binary_exclusive(self.raw_path)
|
||||||
self._raw.write(RAW_MAGIC)
|
self._raw.write(RAW_MAGIC)
|
||||||
self._metadata = _open_text_exclusive(self.metadata_path)
|
self._metadata = _open_text_exclusive(self.metadata_path)
|
||||||
|
_fsync_directory(self.out_dir)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
with suppress(OSError):
|
with suppress(OSError):
|
||||||
self.close()
|
self.close()
|
||||||
|
|
@ -212,7 +219,9 @@ class _CaptureWriter:
|
||||||
"max_message_bytes": self.max_message_bytes,
|
"max_message_bytes": self.max_message_bytes,
|
||||||
"reason": "message_too_large",
|
"reason": "message_too_large",
|
||||||
}
|
}
|
||||||
|
self._commit_pending()
|
||||||
self._write_metadata(metadata, record)
|
self._write_metadata(metadata, record)
|
||||||
|
os.fsync(metadata.fileno())
|
||||||
raise MessageTooLargeError(
|
raise MessageTooLargeError(
|
||||||
f"message on {topic!r} is {len(payload)} bytes; "
|
f"message on {topic!r} is {len(payload)} bytes; "
|
||||||
f"limit is {self.max_message_bytes} bytes"
|
f"limit is {self.max_message_bytes} bytes"
|
||||||
|
|
@ -246,7 +255,17 @@ class _CaptureWriter:
|
||||||
"raw_payload_offset": offset + len(header) + len(topic_bytes),
|
"raw_payload_offset": offset + len(header) + len(topic_bytes),
|
||||||
"raw_frame_bytes": frame_bytes,
|
"raw_frame_bytes": frame_bytes,
|
||||||
}
|
}
|
||||||
self._write_metadata(metadata, record)
|
self._pending_metadata.append(
|
||||||
|
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||||
|
)
|
||||||
|
self._pending_raw_bytes += frame_bytes
|
||||||
|
if (
|
||||||
|
len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
|
||||||
|
or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
|
||||||
|
or time.monotonic() - self._last_commit_monotonic
|
||||||
|
>= GROUP_COMMIT_INTERVAL_SECONDS
|
||||||
|
):
|
||||||
|
self._commit_pending()
|
||||||
return CapturedMqttMessage(
|
return CapturedMqttMessage(
|
||||||
sequence=self.message_count,
|
sequence=self.message_count,
|
||||||
topic=topic,
|
topic=topic,
|
||||||
|
|
@ -261,7 +280,12 @@ class _CaptureWriter:
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
first_error: OSError | None = None
|
first_error: OSError | None = None
|
||||||
# Make raw frames durable before making their JSONL references durable.
|
try:
|
||||||
|
self._commit_pending()
|
||||||
|
except OSError as exc:
|
||||||
|
first_error = exc
|
||||||
|
# Make the magic (including the zero-message case) and any last stream
|
||||||
|
# buffers durable before the handles are released.
|
||||||
for stream in (self._raw, self._metadata):
|
for stream in (self._raw, self._metadata):
|
||||||
if stream is None or stream.closed:
|
if stream is None or stream.closed:
|
||||||
continue
|
continue
|
||||||
|
|
@ -280,6 +304,13 @@ class _CaptureWriter:
|
||||||
if first_error is not None:
|
if first_error is not None:
|
||||||
raise first_error
|
raise first_error
|
||||||
|
|
||||||
|
def maybe_commit(self, now_monotonic: float | None = None) -> None:
|
||||||
|
if not self._pending_metadata:
|
||||||
|
return
|
||||||
|
now = time.monotonic() if now_monotonic is None else now_monotonic
|
||||||
|
if now - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS:
|
||||||
|
self._commit_pending(now_monotonic=now)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def raw_bytes(self) -> int:
|
def raw_bytes(self) -> int:
|
||||||
if self.raw_path.exists():
|
if self.raw_path.exists():
|
||||||
|
|
@ -301,6 +332,26 @@ class _CaptureWriter:
|
||||||
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||||
stream.flush()
|
stream.flush()
|
||||||
|
|
||||||
|
def _commit_pending(self, *, now_monotonic: float | None = None) -> None:
|
||||||
|
if not self._pending_metadata:
|
||||||
|
return
|
||||||
|
raw = self._require_raw()
|
||||||
|
metadata = self._require_metadata()
|
||||||
|
# Group commit invariant: durable raw bytes always precede durable
|
||||||
|
# metadata references. A crash can therefore lose only the bounded
|
||||||
|
# in-memory group, never expose metadata pointing past durable raw.
|
||||||
|
raw.flush()
|
||||||
|
os.fsync(raw.fileno())
|
||||||
|
payload = "".join(self._pending_metadata)
|
||||||
|
self._pending_metadata.clear()
|
||||||
|
self._pending_raw_bytes = 0
|
||||||
|
metadata.write(payload)
|
||||||
|
metadata.flush()
|
||||||
|
os.fsync(metadata.fileno())
|
||||||
|
self._last_commit_monotonic = (
|
||||||
|
time.monotonic() if now_monotonic is None else now_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_private_ipv4(value: str) -> str:
|
def validate_private_ipv4(value: str) -> str:
|
||||||
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
|
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
|
||||||
|
|
@ -536,6 +587,11 @@ def capture_mqtt(
|
||||||
break
|
break
|
||||||
|
|
||||||
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
|
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
writer.maybe_commit()
|
||||||
|
except OSError as exc:
|
||||||
|
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
|
||||||
|
break
|
||||||
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
|
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
|
||||||
fail(
|
fail(
|
||||||
"connection_lost",
|
"connection_lost",
|
||||||
|
|
@ -681,3 +737,12 @@ def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
|
||||||
stream.write(serialized)
|
stream.write(serialized)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
os.fsync(stream.fileno())
|
os.fsync(stream.fileno())
|
||||||
|
_fsync_directory(path.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Host-owned observation session catalog and durable layout persistence."""
|
||||||
|
|
||||||
|
from .active import (
|
||||||
|
ActiveSessionLease,
|
||||||
|
ActiveSessionLeaseError,
|
||||||
|
recover_stale_active_session_marker,
|
||||||
|
)
|
||||||
|
from .media import (
|
||||||
|
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||||
|
RecordedMediaFile,
|
||||||
|
RecordedMediaInspector,
|
||||||
|
RecordedMediaManifest,
|
||||||
|
validate_recorded_media_timeline,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
LayoutConflictError,
|
||||||
|
RecordedMediaArtifact,
|
||||||
|
ReplayCommand,
|
||||||
|
SessionIntegrityError,
|
||||||
|
SessionNotFoundError,
|
||||||
|
SessionNotReplayableError,
|
||||||
|
)
|
||||||
|
from .preparation import (
|
||||||
|
RecordingPreparationQueueFull,
|
||||||
|
RecordingPreparationSnapshot,
|
||||||
|
SessionRecordingPreparationManager,
|
||||||
|
)
|
||||||
|
from .recording import (
|
||||||
|
MaterializedRecording,
|
||||||
|
RecordingMaterializationCancelled,
|
||||||
|
RecordingMaterializationError,
|
||||||
|
SessionRecordingMaterializer,
|
||||||
|
)
|
||||||
|
from .store import (
|
||||||
|
SessionStore,
|
||||||
|
resolve_missioncore_data_dir,
|
||||||
|
resolve_missioncore_evidence_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LayoutConflictError",
|
||||||
|
"ActiveSessionLease",
|
||||||
|
"ActiveSessionLeaseError",
|
||||||
|
"MaterializedRecording",
|
||||||
|
"RecordingMaterializationCancelled",
|
||||||
|
"RecordedMediaArtifact",
|
||||||
|
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||||
|
"RecordedMediaFile",
|
||||||
|
"RecordedMediaInspector",
|
||||||
|
"RecordedMediaManifest",
|
||||||
|
"ReplayCommand",
|
||||||
|
"RecordingMaterializationError",
|
||||||
|
"RecordingPreparationQueueFull",
|
||||||
|
"RecordingPreparationSnapshot",
|
||||||
|
"SessionIntegrityError",
|
||||||
|
"SessionNotFoundError",
|
||||||
|
"SessionNotReplayableError",
|
||||||
|
"SessionRecordingMaterializer",
|
||||||
|
"SessionRecordingPreparationManager",
|
||||||
|
"SessionStore",
|
||||||
|
"recover_stale_active_session_marker",
|
||||||
|
"resolve_missioncore_data_dir",
|
||||||
|
"resolve_missioncore_evidence_dir",
|
||||||
|
"validate_recorded_media_timeline",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
ACTIVE_SESSION_MARKER = ".current_session"
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveSessionLeaseError(RuntimeError):
|
||||||
|
"""The evidence root already has an active writer or cannot be leased."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ActiveSessionLease:
|
||||||
|
"""Cross-process lease that keeps an in-progress session out of replay.
|
||||||
|
|
||||||
|
The marker is intentionally written before the session directory is
|
||||||
|
created. Discovery therefore observes either no candidate yet or a
|
||||||
|
candidate protected by an already locked marker. A process crash releases
|
||||||
|
the OS lock; startup recovery can then remove the stale marker while
|
||||||
|
preserving the interrupted evidence directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sessions_root: Path
|
||||||
|
session_root: Path
|
||||||
|
_descriptor: int
|
||||||
|
_marker_identity: tuple[int, int]
|
||||||
|
_released: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
|
||||||
|
root = sessions_root.expanduser().resolve()
|
||||||
|
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
target = session_root.expanduser().absolute()
|
||||||
|
if target.parent.resolve() != root or not target.name:
|
||||||
|
raise ActiveSessionLeaseError("active session must be a direct child of evidence root")
|
||||||
|
|
||||||
|
marker = root / ACTIVE_SESSION_MARKER
|
||||||
|
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(marker, flags, 0o600)
|
||||||
|
except FileExistsError as exc:
|
||||||
|
raise ActiveSessionLeaseError(
|
||||||
|
"another observation session owns the evidence root"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
payload = f"{target.name}\n".encode()
|
||||||
|
os.write(descriptor, payload)
|
||||||
|
os.fsync(descriptor)
|
||||||
|
_lock_descriptor(descriptor, blocking=False)
|
||||||
|
_fsync_directory(root)
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
return cls(
|
||||||
|
sessions_root=root,
|
||||||
|
session_root=target,
|
||||||
|
_descriptor=descriptor,
|
||||||
|
_marker_identity=(metadata.st_dev, metadata.st_ino),
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
marker.unlink(missing_ok=True)
|
||||||
|
_fsync_directory(root)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if self._released:
|
||||||
|
return
|
||||||
|
marker = self.sessions_root / ACTIVE_SESSION_MARKER
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
metadata = marker.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
metadata = None
|
||||||
|
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
|
||||||
|
marker.unlink()
|
||||||
|
_fsync_directory(self.sessions_root)
|
||||||
|
finally:
|
||||||
|
_unlock_descriptor(self._descriptor)
|
||||||
|
os.close(self._descriptor)
|
||||||
|
self._released = True
|
||||||
|
|
||||||
|
def __enter__(self) -> ActiveSessionLease:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_: object) -> None:
|
||||||
|
self.release()
|
||||||
|
|
||||||
|
|
||||||
|
def recover_stale_active_session_marker(sessions_root: Path) -> bool:
|
||||||
|
"""Remove only an unlocked marker left by a terminated writer."""
|
||||||
|
|
||||||
|
root = sessions_root.expanduser().resolve()
|
||||||
|
marker = root / ACTIVE_SESSION_MARKER
|
||||||
|
try:
|
||||||
|
marker_stat = marker.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
descriptor = os.open(marker, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
locked = False
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
_lock_descriptor(descriptor, blocking=False)
|
||||||
|
locked = True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
opened = os.fstat(descriptor)
|
||||||
|
try:
|
||||||
|
current = marker.lstat()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
|
||||||
|
return False
|
||||||
|
marker.unlink()
|
||||||
|
_fsync_directory(root)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if locked:
|
||||||
|
_unlock_descriptor(descriptor)
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_descriptor(descriptor: int, *, blocking: bool) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
||||||
|
|
||||||
|
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||||
|
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
||||||
|
msvcrt.locking(descriptor, mode, 1)
|
||||||
|
return
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
operation = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
||||||
|
fcntl.flock(descriptor, operation)
|
||||||
|
|
||||||
|
|
||||||
|
def _unlock_descriptor(descriptor: int) -> None:
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
||||||
|
|
||||||
|
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||||
|
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
||||||
|
return
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
@ -0,0 +1,818 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO, Any
|
||||||
|
|
||||||
|
from k1link.mqtt.capture import (
|
||||||
|
FRAME_HEADER,
|
||||||
|
GROUP_COMMIT_MAX_BYTES,
|
||||||
|
GROUP_COMMIT_MAX_MESSAGES,
|
||||||
|
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||||
|
MAX_TOPIC_BYTES,
|
||||||
|
RAW_MAGIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
LegacyMediaSourceCandidate,
|
||||||
|
LegacySessionCandidate,
|
||||||
|
SessionModality,
|
||||||
|
SessionStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_LEGACY_JSON_BYTES = 2 * 1024 * 1024
|
||||||
|
LEGACY_SESSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]+_viewer_live(?:_[0-9]+)?$")
|
||||||
|
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
MEDIA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
MEDIA_EPOCH_PATTERN = re.compile(r"^epoch-(?!0+$)[0-9]+$")
|
||||||
|
MEDIA_SEGMENT_PATTERN = re.compile(r"^[0-9]+\.m4s$")
|
||||||
|
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
|
||||||
|
MAX_RECOVERY_METADATA_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
|
||||||
|
MAX_RECOVERY_MESSAGES = 500_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _RecoveredCapture:
|
||||||
|
message_count: int
|
||||||
|
topic_counts: dict[str, int]
|
||||||
|
started_at_utc: str
|
||||||
|
completed_at_utc: str
|
||||||
|
duration_seconds: float
|
||||||
|
raw_committed_bytes: int
|
||||||
|
metadata_committed_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]:
|
||||||
|
"""Describe legacy live sessions without copying or decoding their payloads."""
|
||||||
|
|
||||||
|
allowed_root = root.expanduser().resolve()
|
||||||
|
if not allowed_root.is_dir():
|
||||||
|
return ()
|
||||||
|
active_session_root = _active_session_root(allowed_root)
|
||||||
|
candidates: list[LegacySessionCandidate] = []
|
||||||
|
for entry in sorted(allowed_root.iterdir()):
|
||||||
|
if not entry.is_dir() or not LEGACY_SESSION_PATTERN.fullmatch(entry.name):
|
||||||
|
continue
|
||||||
|
resolved = entry.resolve()
|
||||||
|
if not resolved.is_relative_to(allowed_root):
|
||||||
|
continue
|
||||||
|
# The saved-session catalog deliberately omits the writer-owned run.
|
||||||
|
# Besides avoiding a misleading interrupted/error row, this prevents
|
||||||
|
# the two-second reconciler from repeatedly scanning and hashing a
|
||||||
|
# capture that is still growing. Marker release makes it discoverable
|
||||||
|
# on the next reconciliation pass.
|
||||||
|
if active_session_root == resolved:
|
||||||
|
continue
|
||||||
|
candidates.append(
|
||||||
|
_describe_session(
|
||||||
|
allowed_root,
|
||||||
|
resolved,
|
||||||
|
active=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_session(
|
||||||
|
allowed_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
*,
|
||||||
|
active: bool,
|
||||||
|
) -> LegacySessionCandidate:
|
||||||
|
capture_root = session_root / "captures" / "mqtt_live"
|
||||||
|
raw_path = capture_root / "mqtt.raw.k1mqtt"
|
||||||
|
summary = _read_json_object(capture_root / "mqtt.summary.json")
|
||||||
|
manifest = _read_json_object(session_root / "manifest.redacted.json")
|
||||||
|
raw_bytes = raw_path.stat().st_size if _confined_file(raw_path, session_root) else 0
|
||||||
|
raw_magic_ok = False
|
||||||
|
if raw_bytes >= len(RAW_MAGIC):
|
||||||
|
with raw_path.open("rb") as stream:
|
||||||
|
raw_magic_ok = stream.read(len(RAW_MAGIC)) == RAW_MAGIC
|
||||||
|
|
||||||
|
has_completed_summary = _is_completed_summary(summary)
|
||||||
|
completed = (
|
||||||
|
_validate_completed_capture(capture_root, session_root, raw_path, summary)
|
||||||
|
if has_completed_summary and raw_magic_ok
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
recovered = (
|
||||||
|
_recover_interrupted_capture(capture_root, session_root, raw_path)
|
||||||
|
if raw_magic_ok
|
||||||
|
and completed is None
|
||||||
|
and (not has_completed_summary or summary.get("error") is not None)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
message_count = (
|
||||||
|
_non_negative_int(summary.get("message_count"))
|
||||||
|
if completed is not None
|
||||||
|
else recovered.message_count if recovered is not None else 0
|
||||||
|
)
|
||||||
|
summary_topic_counts = summary.get("topic_counts")
|
||||||
|
topic_counts = (
|
||||||
|
_normalized_topic_counts(summary_topic_counts)
|
||||||
|
if completed is not None
|
||||||
|
else recovered.topic_counts if recovered is not None else {}
|
||||||
|
)
|
||||||
|
media_sources = _discover_media_sources(session_root)
|
||||||
|
modalities = list(_modalities(topic_counts))
|
||||||
|
if media_sources:
|
||||||
|
modalities.append("video")
|
||||||
|
replayable = bool(
|
||||||
|
not active
|
||||||
|
and
|
||||||
|
raw_magic_ok
|
||||||
|
and raw_bytes > len(RAW_MAGIC)
|
||||||
|
and message_count > 0
|
||||||
|
and any(modality in {"point-cloud", "trajectory"} for modality in modalities)
|
||||||
|
)
|
||||||
|
if completed is not None:
|
||||||
|
status = (
|
||||||
|
"interrupted"
|
||||||
|
if active
|
||||||
|
else _status(replayable, summary.get("error"), summary.get("stop_reason"))
|
||||||
|
)
|
||||||
|
started_at = (
|
||||||
|
_safe_timestamp(summary.get("created_at_utc"))
|
||||||
|
or _safe_timestamp(manifest.get("started_at_utc"))
|
||||||
|
or _timestamp_from_session_name(session_root.name)
|
||||||
|
)
|
||||||
|
completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp(
|
||||||
|
manifest.get("completed_at_utc")
|
||||||
|
)
|
||||||
|
duration = _duration(summary.get("capture_elapsed_seconds"))
|
||||||
|
declared_hash = _declared_raw_hash(summary)
|
||||||
|
integrity_status = "verified" if declared_hash is not None else "validated-structure"
|
||||||
|
else:
|
||||||
|
status = "interrupted" if recovered is not None or active else "failed"
|
||||||
|
started_at = (
|
||||||
|
recovered.started_at_utc
|
||||||
|
if recovered is not None
|
||||||
|
else _safe_timestamp(manifest.get("started_at_utc"))
|
||||||
|
or _timestamp_from_session_name(session_root.name)
|
||||||
|
)
|
||||||
|
completed_at = recovered.completed_at_utc if recovered is not None else None
|
||||||
|
duration = recovered.duration_seconds if recovered is not None else None
|
||||||
|
declared_hash = None
|
||||||
|
integrity_status = "validated-prefix" if recovered is not None else "unverified"
|
||||||
|
|
||||||
|
replay_raw_bytes = (
|
||||||
|
completed.raw_committed_bytes
|
||||||
|
if completed is not None
|
||||||
|
else recovered.raw_committed_bytes if recovered is not None else 0
|
||||||
|
)
|
||||||
|
replay_metadata_bytes = (
|
||||||
|
completed.metadata_committed_bytes
|
||||||
|
if completed is not None
|
||||||
|
else recovered.metadata_committed_bytes if recovered is not None else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return LegacySessionCandidate(
|
||||||
|
session_id=session_root.name,
|
||||||
|
display_name=session_root.name,
|
||||||
|
status=status,
|
||||||
|
started_at_utc=started_at,
|
||||||
|
completed_at_utc=completed_at,
|
||||||
|
duration_seconds=duration,
|
||||||
|
modalities=tuple(modalities),
|
||||||
|
replayable=replayable,
|
||||||
|
total_bytes=raw_bytes + sum(source.byte_length for source in media_sources),
|
||||||
|
allowed_root=allowed_root,
|
||||||
|
session_root=session_root,
|
||||||
|
raw_path=raw_path.resolve(strict=False),
|
||||||
|
raw_byte_length=raw_bytes,
|
||||||
|
replay_raw_byte_length=replay_raw_bytes,
|
||||||
|
replay_metadata_byte_length=replay_metadata_bytes,
|
||||||
|
raw_sha256=declared_hash,
|
||||||
|
raw_integrity_status=integrity_status,
|
||||||
|
media_sources=media_sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_completed_summary(summary: dict[str, Any]) -> bool:
|
||||||
|
return (
|
||||||
|
"message_count" in summary
|
||||||
|
and isinstance(summary.get("message_count"), int)
|
||||||
|
and not isinstance(summary.get("message_count"), bool)
|
||||||
|
and isinstance(summary.get("topic_counts"), dict)
|
||||||
|
and isinstance(summary.get("stop_reason"), str)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_completed_capture(
|
||||||
|
capture_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
raw_path: Path,
|
||||||
|
summary: dict[str, Any],
|
||||||
|
) -> _RecoveredCapture | None:
|
||||||
|
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||||
|
try:
|
||||||
|
raw_stat = raw_path.lstat()
|
||||||
|
metadata_stat = metadata_path.lstat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not stat.S_ISREG(raw_stat.st_mode) or not stat.S_ISREG(metadata_stat.st_mode):
|
||||||
|
return None
|
||||||
|
fingerprint = json.dumps(
|
||||||
|
{
|
||||||
|
"message_count": summary.get("message_count"),
|
||||||
|
"topic_counts": summary.get("topic_counts"),
|
||||||
|
"raw_bytes": summary.get("raw_bytes"),
|
||||||
|
"artifact_hashes": summary.get("artifact_hashes"),
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return _validate_completed_capture_cached(
|
||||||
|
str(capture_root),
|
||||||
|
str(session_root),
|
||||||
|
str(raw_path),
|
||||||
|
_stat_identity(raw_stat),
|
||||||
|
_stat_identity(metadata_stat),
|
||||||
|
fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def _validate_completed_capture_cached(
|
||||||
|
capture_root_text: str,
|
||||||
|
session_root_text: str,
|
||||||
|
raw_path_text: str,
|
||||||
|
_raw_identity: tuple[int, int, int, int, int],
|
||||||
|
_metadata_identity: tuple[int, int, int, int, int],
|
||||||
|
summary_fingerprint: str,
|
||||||
|
) -> _RecoveredCapture | None:
|
||||||
|
capture_root = Path(capture_root_text)
|
||||||
|
session_root = Path(session_root_text)
|
||||||
|
raw_path = Path(raw_path_text)
|
||||||
|
summary = json.loads(summary_fingerprint)
|
||||||
|
validated = _scan_capture_prefix(
|
||||||
|
capture_root,
|
||||||
|
session_root,
|
||||||
|
raw_path,
|
||||||
|
tolerate_incomplete_metadata_tail=False,
|
||||||
|
tolerate_raw_crash_tail=False,
|
||||||
|
)
|
||||||
|
if validated is None:
|
||||||
|
return None
|
||||||
|
if validated.message_count != _non_negative_int(summary.get("message_count")):
|
||||||
|
return None
|
||||||
|
if validated.topic_counts != _normalized_topic_counts(summary.get("topic_counts")):
|
||||||
|
return None
|
||||||
|
declared_raw_bytes = summary.get("raw_bytes")
|
||||||
|
if declared_raw_bytes is not None and (
|
||||||
|
not isinstance(declared_raw_bytes, int)
|
||||||
|
or isinstance(declared_raw_bytes, bool)
|
||||||
|
or declared_raw_bytes != validated.raw_committed_bytes
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
raw_hash = _declared_raw_hash(summary)
|
||||||
|
if raw_hash is not None and _sha256_stable(raw_path) != raw_hash:
|
||||||
|
return None
|
||||||
|
metadata_hash = _declared_metadata_hash(summary)
|
||||||
|
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||||
|
if metadata_hash is not None and _sha256_stable(metadata_path) != metadata_hash:
|
||||||
|
return None
|
||||||
|
return validated
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_interrupted_capture(
|
||||||
|
capture_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
raw_path: Path,
|
||||||
|
) -> _RecoveredCapture | None:
|
||||||
|
return _scan_capture_prefix(
|
||||||
|
capture_root,
|
||||||
|
session_root,
|
||||||
|
raw_path,
|
||||||
|
tolerate_incomplete_metadata_tail=True,
|
||||||
|
tolerate_raw_crash_tail=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_capture_prefix(
|
||||||
|
capture_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
raw_path: Path,
|
||||||
|
*,
|
||||||
|
tolerate_incomplete_metadata_tail: bool,
|
||||||
|
tolerate_raw_crash_tail: bool,
|
||||||
|
) -> _RecoveredCapture | None:
|
||||||
|
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||||
|
if not _confined_file(raw_path, session_root) or not _confined_file(
|
||||||
|
metadata_path, session_root
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw_size = raw_path.stat().st_size
|
||||||
|
with raw_path.open("rb") as raw_stream, metadata_path.open("rb") as metadata_stream:
|
||||||
|
if raw_stream.read(len(RAW_MAGIC)) != RAW_MAGIC:
|
||||||
|
return None
|
||||||
|
consumed_metadata_bytes = 0
|
||||||
|
message_count = 0
|
||||||
|
topic_counts: dict[str, int] = {}
|
||||||
|
first_epoch_ns: int | None = None
|
||||||
|
last_epoch_ns: int | None = None
|
||||||
|
first_monotonic_ns: int | None = None
|
||||||
|
last_monotonic_ns: int | None = None
|
||||||
|
first_timestamp: str | None = None
|
||||||
|
last_timestamp: str | None = None
|
||||||
|
metadata_committed_bytes = 0
|
||||||
|
while (
|
||||||
|
consumed_metadata_bytes < MAX_RECOVERY_METADATA_BYTES
|
||||||
|
and message_count < MAX_RECOVERY_MESSAGES
|
||||||
|
):
|
||||||
|
remaining = MAX_RECOVERY_METADATA_BYTES - consumed_metadata_bytes
|
||||||
|
read_limit = min(MAX_RECOVERY_METADATA_LINE_BYTES + 1, remaining + 1)
|
||||||
|
line = metadata_stream.readline(read_limit)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
consumed_metadata_bytes += len(line)
|
||||||
|
if (
|
||||||
|
len(line) > MAX_RECOVERY_METADATA_LINE_BYTES
|
||||||
|
or consumed_metadata_bytes > MAX_RECOVERY_METADATA_BYTES
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if not line.endswith(b"\n"):
|
||||||
|
if tolerate_incomplete_metadata_tail:
|
||||||
|
break
|
||||||
|
return None
|
||||||
|
record = _metadata_record(line)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
metadata_committed_bytes = consumed_metadata_bytes
|
||||||
|
if record.get("record_type") != "message":
|
||||||
|
continue
|
||||||
|
expected_sequence = message_count + 1
|
||||||
|
validated = _validate_recovery_frame(
|
||||||
|
raw_stream,
|
||||||
|
raw_size=raw_size,
|
||||||
|
record=record,
|
||||||
|
expected_sequence=expected_sequence,
|
||||||
|
)
|
||||||
|
if validated is None:
|
||||||
|
return None
|
||||||
|
topic, epoch_ns, monotonic_ns, timestamp = validated
|
||||||
|
if last_monotonic_ns is not None and monotonic_ns < last_monotonic_ns:
|
||||||
|
return None
|
||||||
|
message_count = expected_sequence
|
||||||
|
topic_counts[topic] = topic_counts.get(topic, 0) + 1
|
||||||
|
if first_epoch_ns is None:
|
||||||
|
first_epoch_ns = epoch_ns
|
||||||
|
first_monotonic_ns = monotonic_ns
|
||||||
|
first_timestamp = timestamp
|
||||||
|
last_epoch_ns = epoch_ns
|
||||||
|
last_monotonic_ns = monotonic_ns
|
||||||
|
last_timestamp = timestamp
|
||||||
|
if metadata_stream.read(1):
|
||||||
|
return None
|
||||||
|
raw_committed_bytes = raw_stream.tell()
|
||||||
|
if raw_committed_bytes != raw_size and not (
|
||||||
|
tolerate_raw_crash_tail
|
||||||
|
and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
message_count == 0
|
||||||
|
or first_epoch_ns is None
|
||||||
|
or last_epoch_ns is None
|
||||||
|
or first_timestamp is None
|
||||||
|
or last_timestamp is None
|
||||||
|
or first_monotonic_ns is None
|
||||||
|
or last_monotonic_ns is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return _RecoveredCapture(
|
||||||
|
message_count=message_count,
|
||||||
|
topic_counts=topic_counts,
|
||||||
|
started_at_utc=first_timestamp,
|
||||||
|
completed_at_utc=last_timestamp,
|
||||||
|
duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000,
|
||||||
|
raw_committed_bytes=raw_committed_bytes,
|
||||||
|
metadata_committed_bytes=metadata_committed_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_record(line: bytes) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
value = json.loads(line.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _tolerable_raw_crash_tail(raw_stream: IO[bytes], *, raw_size: int) -> bool:
|
||||||
|
"""Accept at most one raw frame which was not committed by metadata.
|
||||||
|
|
||||||
|
The capture writer flushes raw before metadata and does not begin the next
|
||||||
|
message until the current metadata record is written. Consequently a
|
||||||
|
process crash can leave only a prefix (or all) of one additional frame.
|
||||||
|
Anything beyond that boundary is corruption, not a recoverable tail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tail_offset = raw_stream.tell()
|
||||||
|
tail_bytes = raw_size - tail_offset
|
||||||
|
if tail_bytes <= 0:
|
||||||
|
return True
|
||||||
|
if tail_bytes > GROUP_COMMIT_MAX_BYTES + MAX_CONFIGURABLE_MESSAGE_BYTES:
|
||||||
|
return False
|
||||||
|
frames = 0
|
||||||
|
while raw_stream.tell() < raw_size:
|
||||||
|
remaining = raw_size - raw_stream.tell()
|
||||||
|
header = raw_stream.read(min(remaining, FRAME_HEADER.size))
|
||||||
|
if len(header) < FRAME_HEADER.size:
|
||||||
|
return frames < GROUP_COMMIT_MAX_MESSAGES
|
||||||
|
topic_length, payload_length = FRAME_HEADER.unpack(header)
|
||||||
|
if not 1 <= topic_length <= MAX_TOPIC_BYTES:
|
||||||
|
return False
|
||||||
|
if payload_length > MAX_CONFIGURABLE_MESSAGE_BYTES:
|
||||||
|
return False
|
||||||
|
frame_bytes = FRAME_HEADER.size + topic_length + payload_length
|
||||||
|
frame_remaining = raw_size - (raw_stream.tell() - FRAME_HEADER.size)
|
||||||
|
available_topic_bytes = min(
|
||||||
|
topic_length,
|
||||||
|
max(0, frame_remaining - FRAME_HEADER.size),
|
||||||
|
)
|
||||||
|
topic_prefix = raw_stream.read(available_topic_bytes)
|
||||||
|
if available_topic_bytes < topic_length:
|
||||||
|
return frames < GROUP_COMMIT_MAX_MESSAGES
|
||||||
|
try:
|
||||||
|
topic = topic_prefix.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return False
|
||||||
|
if not topic:
|
||||||
|
return False
|
||||||
|
payload_available = frame_remaining - FRAME_HEADER.size - topic_length
|
||||||
|
if payload_available < payload_length:
|
||||||
|
return frames < GROUP_COMMIT_MAX_MESSAGES
|
||||||
|
raw_stream.seek(payload_length, 1)
|
||||||
|
frames += 1
|
||||||
|
if frames > GROUP_COMMIT_MAX_MESSAGES or frame_remaining < frame_bytes:
|
||||||
|
return False
|
||||||
|
return frames <= GROUP_COMMIT_MAX_MESSAGES
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_recovery_frame(
|
||||||
|
raw_stream: IO[bytes],
|
||||||
|
*,
|
||||||
|
raw_size: int,
|
||||||
|
record: dict[str, Any],
|
||||||
|
expected_sequence: int,
|
||||||
|
) -> tuple[str, int, int, str] | None:
|
||||||
|
sequence = record.get("sequence")
|
||||||
|
topic = record.get("topic")
|
||||||
|
payload_bytes = record.get("payload_bytes")
|
||||||
|
frame_offset = record.get("raw_frame_offset")
|
||||||
|
payload_offset = record.get("raw_payload_offset")
|
||||||
|
frame_bytes = record.get("raw_frame_bytes")
|
||||||
|
epoch_ns = record.get("received_at_epoch_ns")
|
||||||
|
monotonic_ns = record.get("received_monotonic_ns")
|
||||||
|
if (
|
||||||
|
sequence != expected_sequence
|
||||||
|
or not isinstance(topic, str)
|
||||||
|
or not topic
|
||||||
|
or not isinstance(payload_bytes, int)
|
||||||
|
or isinstance(payload_bytes, bool)
|
||||||
|
or not 0 <= payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES
|
||||||
|
or not isinstance(frame_offset, int)
|
||||||
|
or isinstance(frame_offset, bool)
|
||||||
|
or frame_offset != raw_stream.tell()
|
||||||
|
or not isinstance(payload_offset, int)
|
||||||
|
or isinstance(payload_offset, bool)
|
||||||
|
or not isinstance(frame_bytes, int)
|
||||||
|
or isinstance(frame_bytes, bool)
|
||||||
|
or not isinstance(epoch_ns, int)
|
||||||
|
or isinstance(epoch_ns, bool)
|
||||||
|
or epoch_ns < 0
|
||||||
|
or not isinstance(monotonic_ns, int)
|
||||||
|
or isinstance(monotonic_ns, bool)
|
||||||
|
or monotonic_ns < 0
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
header = raw_stream.read(FRAME_HEADER.size)
|
||||||
|
if len(header) != FRAME_HEADER.size:
|
||||||
|
return None
|
||||||
|
topic_length, raw_payload_bytes = FRAME_HEADER.unpack(header)
|
||||||
|
if (
|
||||||
|
not 1 <= topic_length <= MAX_TOPIC_BYTES
|
||||||
|
or raw_payload_bytes != payload_bytes
|
||||||
|
or payload_offset != frame_offset + FRAME_HEADER.size + topic_length
|
||||||
|
or frame_bytes != FRAME_HEADER.size + topic_length + payload_bytes
|
||||||
|
or frame_offset + frame_bytes > raw_size
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
raw_topic = raw_stream.read(topic_length)
|
||||||
|
try:
|
||||||
|
decoded_topic = raw_topic.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
if decoded_topic != topic:
|
||||||
|
return None
|
||||||
|
raw_stream.seek(payload_bytes, 1)
|
||||||
|
timestamp = _safe_timestamp(record.get("received_at_utc"))
|
||||||
|
if timestamp is None:
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromtimestamp(epoch_ns / 1_000_000_000, tz=UTC).isoformat()
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
return None
|
||||||
|
return topic, epoch_ns, monotonic_ns, timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_object(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
if not path.is_file() or path.stat().st_size > MAX_LEGACY_JSON_BYTES:
|
||||||
|
return {}
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _confined_file(path: Path, session_root: Path) -> bool:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return resolved.is_file() and resolved.is_relative_to(session_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _non_negative_int(value: object) -> int:
|
||||||
|
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_topic_counts(value: object) -> dict[str, int]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for topic, raw_count in value.items():
|
||||||
|
count = _non_negative_int(raw_count)
|
||||||
|
if isinstance(topic, str) and count > 0:
|
||||||
|
result[topic] = count
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _duration(value: object) -> float | None:
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
duration = float(value)
|
||||||
|
return duration if math.isfinite(duration) and duration >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_timestamp(value: object) -> str | None:
|
||||||
|
if not isinstance(value, str) or len(value) > 64:
|
||||||
|
return None
|
||||||
|
candidate = value.strip()
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_from_session_name(session_id: str) -> str | None:
|
||||||
|
prefix = session_id.split("_", 1)[0]
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(prefix, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_raw_hash(summary: dict[str, Any]) -> str | None:
|
||||||
|
hashes = summary.get("artifact_hashes")
|
||||||
|
if not isinstance(hashes, dict):
|
||||||
|
return None
|
||||||
|
value = hashes.get("raw_sha256")
|
||||||
|
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_metadata_hash(summary: dict[str, Any]) -> str | None:
|
||||||
|
hashes = summary.get("artifact_hashes")
|
||||||
|
if not isinstance(hashes, dict):
|
||||||
|
return None
|
||||||
|
value = hashes.get("metadata_jsonl_sha256")
|
||||||
|
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_stable(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
current = path.lstat()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
if not stat.S_ISREG(current.st_mode):
|
||||||
|
return ""
|
||||||
|
return _sha256_cached(str(path), _stat_identity(current))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def _sha256_cached(
|
||||||
|
path_text: str,
|
||||||
|
expected_identity: tuple[int, int, int, int, int],
|
||||||
|
) -> str:
|
||||||
|
path = Path(path_text)
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
before = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(before.st_mode) or _stat_identity(before) != expected_identity:
|
||||||
|
return ""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
while chunk := os.read(descriptor, 1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
after = os.fstat(descriptor)
|
||||||
|
try:
|
||||||
|
current = os.lstat(path)
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
if _stat_identity(before) != _stat_identity(after) or (
|
||||||
|
current.st_dev,
|
||||||
|
current.st_ino,
|
||||||
|
) != (before.st_dev, before.st_ino):
|
||||||
|
return ""
|
||||||
|
return digest.hexdigest()
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _stat_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
|
||||||
|
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_session_root(root: Path) -> Path | None:
|
||||||
|
marker = root / ".current_session"
|
||||||
|
try:
|
||||||
|
marker_stat = marker.lstat()
|
||||||
|
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
|
||||||
|
return None
|
||||||
|
value = marker.read_text(encoding="utf-8").strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
raw = Path(value).expanduser()
|
||||||
|
if raw.is_absolute():
|
||||||
|
candidate = raw
|
||||||
|
elif raw.parts and raw.parts[0] == root.name:
|
||||||
|
candidate = root.parent / raw
|
||||||
|
else:
|
||||||
|
candidate = root / raw
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _modalities(topic_counts: Mapping[str, int]) -> tuple[SessionModality, ...]:
|
||||||
|
topics = {
|
||||||
|
topic
|
||||||
|
for topic, count in topic_counts.items()
|
||||||
|
if isinstance(topic, str) and _non_negative_int(count) > 0
|
||||||
|
}
|
||||||
|
result: list[SessionModality] = []
|
||||||
|
if any(topic == "RealtimePointcloud" or topic.endswith("/lio_pcl") for topic in topics):
|
||||||
|
result.append("point-cloud")
|
||||||
|
if any(topic == "RealtimePath" or topic.endswith("/lio_pose") for topic in topics):
|
||||||
|
result.append("trajectory")
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _status(replayable: bool, error: object, stop_reason: object) -> SessionStatus:
|
||||||
|
if error is None and stop_reason in {
|
||||||
|
"duration_elapsed",
|
||||||
|
"external_stop",
|
||||||
|
"keyboard_interrupt",
|
||||||
|
}:
|
||||||
|
return "ready" if replayable else "failed"
|
||||||
|
return "interrupted" if replayable else "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_media_sources(session_root: Path) -> tuple[LegacyMediaSourceCandidate, ...]:
|
||||||
|
media_root = session_root / "media"
|
||||||
|
try:
|
||||||
|
resolved_media_root = media_root.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return ()
|
||||||
|
if not resolved_media_root.is_dir() or not resolved_media_root.is_relative_to(session_root):
|
||||||
|
return ()
|
||||||
|
candidates: list[LegacyMediaSourceCandidate] = []
|
||||||
|
for source_root in sorted(resolved_media_root.iterdir()):
|
||||||
|
if not source_root.is_dir() or not MEDIA_SOURCE_PATTERN.fullmatch(source_root.name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
resolved_source_root = source_root.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not resolved_source_root.is_relative_to(resolved_media_root):
|
||||||
|
continue
|
||||||
|
epochs = [
|
||||||
|
epoch
|
||||||
|
for epoch in sorted(resolved_source_root.iterdir())
|
||||||
|
if epoch.is_dir()
|
||||||
|
and MEDIA_EPOCH_PATTERN.fullmatch(epoch.name)
|
||||||
|
and _validated_media_epoch(epoch, resolved_source_root.name)
|
||||||
|
]
|
||||||
|
if not epochs:
|
||||||
|
continue
|
||||||
|
byte_length = sum(_media_epoch_bytes(epoch) for epoch in epochs)
|
||||||
|
artifact_suffix = hashlib.sha256(resolved_source_root.name.encode()).hexdigest()[:16]
|
||||||
|
candidates.append(
|
||||||
|
LegacyMediaSourceCandidate(
|
||||||
|
source_id=resolved_source_root.name,
|
||||||
|
artifact_id=f"recorded-video-{artifact_suffix}",
|
||||||
|
locator=resolved_source_root,
|
||||||
|
byte_length=byte_length,
|
||||||
|
epoch_count=len(epochs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
resolved_epoch = epoch.resolve(strict=True)
|
||||||
|
init_path = (epoch / "init.mp4").resolve(strict=True)
|
||||||
|
segments_root = (epoch / "segments").resolve(strict=True)
|
||||||
|
index_path = (epoch / "index.jsonl").resolve(strict=True)
|
||||||
|
summary_path = (epoch / "summary.json").resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if not all(
|
||||||
|
path.is_relative_to(resolved_epoch)
|
||||||
|
for path in (init_path, segments_root, index_path, summary_path)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
not init_path.is_file()
|
||||||
|
or init_path.stat().st_size <= 0
|
||||||
|
or not segments_root.is_dir()
|
||||||
|
or not index_path.is_file()
|
||||||
|
or not 0 < index_path.stat().st_size <= MAX_MEDIA_INDEX_BYTES
|
||||||
|
or not summary_path.is_file()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
summary = _read_json_object(summary_path)
|
||||||
|
if summary.get("schema_version") not in {1, "missioncore.camera-recording/v1"}:
|
||||||
|
return False
|
||||||
|
if summary.get("source_id") != expected_source_id:
|
||||||
|
return False
|
||||||
|
segment_count = _non_negative_int(summary.get("segment_count"))
|
||||||
|
if segment_count < 1:
|
||||||
|
return False
|
||||||
|
segments = [
|
||||||
|
path.resolve()
|
||||||
|
for path in sorted(segments_root.iterdir())
|
||||||
|
if path.is_file() and MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
|
||||||
|
]
|
||||||
|
if len(segments) != segment_count or any(
|
||||||
|
not path.is_relative_to(segments_root) or path.stat().st_size <= 0 for path in segments
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
index_records = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in index_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
if len(index_records) != segment_count or not all(
|
||||||
|
isinstance(record, dict)
|
||||||
|
and _non_negative_int(record.get("sequence")) > 0
|
||||||
|
for record in index_records
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
sequences = [int(record["sequence"]) for record in index_records]
|
||||||
|
return len(sequences) == len(set(sequences))
|
||||||
|
|
||||||
|
|
||||||
|
def _media_epoch_bytes(epoch: Path) -> int:
|
||||||
|
try:
|
||||||
|
resolved_epoch = epoch.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
total = 0
|
||||||
|
for path in resolved_epoch.rglob("*"):
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if resolved.is_file() and resolved.is_relative_to(resolved_epoch):
|
||||||
|
total += resolved.stat().st_size
|
||||||
|
return total
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,221 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
SessionStatus = Literal["ready", "interrupted", "failed"]
|
||||||
|
SessionModality = Literal["point-cloud", "trajectory", "video"]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStoreError(RuntimeError):
|
||||||
|
"""Base error for the host-owned observation session store."""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionNotFoundError(SessionStoreError):
|
||||||
|
"""The requested opaque session identifier is not registered."""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionNotReplayableError(SessionStoreError):
|
||||||
|
"""The session has no reviewed replay source."""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionIntegrityError(SessionStoreError):
|
||||||
|
"""A stored artifact no longer satisfies its confinement/integrity boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutConflictError(SessionStoreError):
|
||||||
|
"""A workspace layout revision changed since the caller loaded it."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SessionSource:
|
||||||
|
source_id: str
|
||||||
|
semantic_channel_id: str
|
||||||
|
modality: SessionModality
|
||||||
|
status: str
|
||||||
|
seekable: bool
|
||||||
|
artifact_id: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source_id": self.source_id,
|
||||||
|
"semantic_channel_id": self.semantic_channel_id,
|
||||||
|
"modality": self.modality,
|
||||||
|
"status": self.status,
|
||||||
|
"seekable": self.seekable,
|
||||||
|
"artifact_id": self.artifact_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SessionArtifact:
|
||||||
|
artifact_id: str
|
||||||
|
kind: str
|
||||||
|
media_type: str
|
||||||
|
byte_length: int
|
||||||
|
sha256: str | None
|
||||||
|
integrity_status: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"artifact_id": self.artifact_id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"media_type": self.media_type,
|
||||||
|
"byte_length": self.byte_length,
|
||||||
|
"sha256": self.sha256,
|
||||||
|
"integrity_status": self.integrity_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SessionSummary:
|
||||||
|
session_id: str
|
||||||
|
display_name: str
|
||||||
|
status: SessionStatus
|
||||||
|
started_at_utc: str | None
|
||||||
|
completed_at_utc: str | None
|
||||||
|
duration_seconds: float | None
|
||||||
|
modalities: tuple[SessionModality, ...]
|
||||||
|
source_count: int
|
||||||
|
total_bytes: int
|
||||||
|
replayable: bool
|
||||||
|
origin: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.observation-session-summary/v1",
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"status": self.status,
|
||||||
|
"started_at_utc": self.started_at_utc,
|
||||||
|
"completed_at_utc": self.completed_at_utc,
|
||||||
|
"duration_seconds": self.duration_seconds,
|
||||||
|
"modalities": list(self.modalities),
|
||||||
|
"source_count": self.source_count,
|
||||||
|
"total_bytes": self.total_bytes,
|
||||||
|
"replayable": self.replayable,
|
||||||
|
"origin": self.origin,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SessionDetail:
|
||||||
|
summary: SessionSummary
|
||||||
|
sources: tuple[SessionSource, ...]
|
||||||
|
artifacts: tuple[SessionArtifact, ...]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
duration = self.summary.duration_seconds
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.observation-session/v1",
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in self.summary.as_dict().items()
|
||||||
|
if key != "schema_version"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"mode": "recorded" if self.summary.replayable else "unavailable",
|
||||||
|
"seekable": self.summary.replayable,
|
||||||
|
"start_seconds": 0.0 if self.summary.replayable else None,
|
||||||
|
"end_seconds": duration if self.summary.replayable else None,
|
||||||
|
"synchronization": "host-arrival-best-effort",
|
||||||
|
},
|
||||||
|
"sources": [source.as_dict() for source in self.sources],
|
||||||
|
"artifacts": [artifact.as_dict() for artifact in self.artifacts],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SessionPage:
|
||||||
|
items: tuple[SessionSummary, ...]
|
||||||
|
next_cursor: str | None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.observation-session-list/v1",
|
||||||
|
"items": [item.as_dict() for item in self.items],
|
||||||
|
"next_cursor": self.next_cursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkspaceLayout:
|
||||||
|
workspace_id: str
|
||||||
|
schema_version: int
|
||||||
|
revision: int
|
||||||
|
name: str
|
||||||
|
layout: dict[str, Any]
|
||||||
|
updated_at_utc: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.workspace-layout/v1",
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"layout_schema_version": self.schema_version,
|
||||||
|
"revision": self.revision,
|
||||||
|
"name": self.name,
|
||||||
|
"layout": self.layout,
|
||||||
|
"updated_at_utc": self.updated_at_utc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReplayCommand:
|
||||||
|
"""Internal-only replay command. ``source_path`` never enters an API DTO."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
source_path: Path
|
||||||
|
allowed_root: Path
|
||||||
|
session_root: Path
|
||||||
|
replay_byte_length: int
|
||||||
|
metadata_byte_length: int
|
||||||
|
expected_source_sha256: str | None
|
||||||
|
speed: float
|
||||||
|
loop: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecordedMediaArtifact:
|
||||||
|
"""Internal handle for one confined archived camera source.
|
||||||
|
|
||||||
|
The physical source id and filesystem locator never enter the public API.
|
||||||
|
``public_source_id`` and ``artifact_id`` are opaque catalog identifiers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
public_source_id: str
|
||||||
|
artifact_id: str
|
||||||
|
source_path: Path
|
||||||
|
byte_length: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LegacySessionCandidate:
|
||||||
|
session_id: str
|
||||||
|
display_name: str
|
||||||
|
status: SessionStatus
|
||||||
|
started_at_utc: str | None
|
||||||
|
completed_at_utc: str | None
|
||||||
|
duration_seconds: float | None
|
||||||
|
modalities: tuple[SessionModality, ...]
|
||||||
|
replayable: bool
|
||||||
|
total_bytes: int
|
||||||
|
allowed_root: Path
|
||||||
|
session_root: Path
|
||||||
|
raw_path: Path
|
||||||
|
raw_byte_length: int
|
||||||
|
replay_raw_byte_length: int
|
||||||
|
replay_metadata_byte_length: int
|
||||||
|
raw_sha256: str | None
|
||||||
|
raw_integrity_status: str
|
||||||
|
media_sources: tuple[LegacyMediaSourceCandidate, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LegacyMediaSourceCandidate:
|
||||||
|
source_id: str
|
||||||
|
artifact_id: str
|
||||||
|
locator: Path
|
||||||
|
byte_length: int
|
||||||
|
epoch_count: int
|
||||||
|
|
@ -0,0 +1,617 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Literal, cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .media import RecordedMediaManifest, validate_recorded_media_timeline
|
||||||
|
from .models import ReplayCommand
|
||||||
|
from .recording import (
|
||||||
|
MaterializedRecording,
|
||||||
|
RecordingMaterializationCancelled,
|
||||||
|
SessionRecordingMaterializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
PreparationState = Literal[
|
||||||
|
"queued",
|
||||||
|
"validating",
|
||||||
|
"exporting",
|
||||||
|
"finalizing",
|
||||||
|
"ready",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
]
|
||||||
|
ACTIVE_PREPARATION_STATES = frozenset({"queued", "validating", "exporting", "finalizing"})
|
||||||
|
_PREPARATION_PHASE: dict[PreparationState, int] = {
|
||||||
|
"queued": 0,
|
||||||
|
"validating": 1,
|
||||||
|
"exporting": 2,
|
||||||
|
"finalizing": 3,
|
||||||
|
"ready": 4,
|
||||||
|
"failed": 4,
|
||||||
|
"cancelled": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingPreparationQueueFull(RuntimeError):
|
||||||
|
"""The bounded conversion queue cannot accept another recording."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecordingPreparationSnapshot:
|
||||||
|
preparation_id: str
|
||||||
|
session_id: str
|
||||||
|
state: PreparationState
|
||||||
|
progress: float
|
||||||
|
updated_at_utc: str
|
||||||
|
cancellable: bool
|
||||||
|
retryable: bool
|
||||||
|
error: str | None
|
||||||
|
command: ReplayCommand
|
||||||
|
recording: MaterializedRecording | None
|
||||||
|
recorded_media: tuple[RecordedMediaManifest, ...] | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _PreparationJob:
|
||||||
|
preparation_id: str
|
||||||
|
source_identity: tuple[object, ...]
|
||||||
|
# This is an immutable source-preparation command. Per-browser playback
|
||||||
|
# policy (speed/loop) never belongs to a shared conversion job.
|
||||||
|
command: ReplayCommand
|
||||||
|
state: PreparationState = "queued"
|
||||||
|
progress: float = 0.0
|
||||||
|
updated_at_utc: str = field(default_factory=lambda: _utc_now_iso())
|
||||||
|
error: str | None = None
|
||||||
|
recording: MaterializedRecording | None = None
|
||||||
|
recorded_media: tuple[RecordedMediaManifest, ...] | None = None
|
||||||
|
cancel_event: threading.Event = field(default_factory=threading.Event)
|
||||||
|
last_activity_monotonic: float = field(default_factory=monotonic)
|
||||||
|
interrupted_by_restart: bool = False
|
||||||
|
cancelled_by_operator: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _WorkerGeneration:
|
||||||
|
generation_id: int
|
||||||
|
work_queue: queue.Queue[_PreparationJob]
|
||||||
|
stop_event: threading.Event = field(default_factory=threading.Event)
|
||||||
|
worker: threading.Thread | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRecordingPreparationManager:
|
||||||
|
"""One bounded, process-owned conversion worker for durable recordings.
|
||||||
|
|
||||||
|
Jobs are keyed by the session plus an inexpensive source identity. They
|
||||||
|
intentionally outlive HTTP requests and browser tabs. Conversion stays
|
||||||
|
single-worker to bound Rerun's CPU, memory and temporary-disk pressure.
|
||||||
|
|
||||||
|
Worker generations make lifespan restart safe even if a third-party
|
||||||
|
exporter ignores cancellation longer than ``close(timeout=...)``. New
|
||||||
|
jobs can queue immediately, but a successor worker starts only after the
|
||||||
|
previous generation has actually exited, so two writers never overlap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
materializer: SessionRecordingMaterializer,
|
||||||
|
*,
|
||||||
|
queue_capacity: int = 128,
|
||||||
|
heartbeat_interval_seconds: float = 5.0,
|
||||||
|
ready_preparer: Callable[
|
||||||
|
[ReplayCommand, MaterializedRecording],
|
||||||
|
tuple[RecordedMediaManifest, ...],
|
||||||
|
]
|
||||||
|
| None = None,
|
||||||
|
) -> None:
|
||||||
|
if queue_capacity < 1:
|
||||||
|
raise ValueError("recording preparation queue capacity must be positive")
|
||||||
|
if heartbeat_interval_seconds <= 0:
|
||||||
|
raise ValueError("recording preparation heartbeat interval must be positive")
|
||||||
|
self.materializer = materializer
|
||||||
|
self._queue_capacity = queue_capacity
|
||||||
|
# Activity callbacks from the real exporter are rate-limited to this
|
||||||
|
# interval. There is deliberately no independent fake heartbeat: a
|
||||||
|
# hung exporter must become observable to the browser stall detector.
|
||||||
|
self._heartbeat_interval_seconds = heartbeat_interval_seconds
|
||||||
|
self._ready_preparer = ready_preparer
|
||||||
|
self._guard = threading.RLock()
|
||||||
|
self._current_by_session: dict[str, _PreparationJob] = {}
|
||||||
|
self._closed = True
|
||||||
|
self._generation_counter = 0
|
||||||
|
self._active_generation: _WorkerGeneration | None = None
|
||||||
|
self._pending_generation: _WorkerGeneration | None = None
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start or restart the single worker for an application lifespan."""
|
||||||
|
|
||||||
|
with self._guard:
|
||||||
|
if not self._closed:
|
||||||
|
return
|
||||||
|
self._current_by_session = {
|
||||||
|
session_id: job
|
||||||
|
for session_id, job in self._current_by_session.items()
|
||||||
|
if job.state != "cancelled"
|
||||||
|
}
|
||||||
|
self._closed = False
|
||||||
|
active = self._active_generation
|
||||||
|
if active is not None and active.worker is not None and active.worker.is_alive():
|
||||||
|
self._pending_generation = self._new_generation_locked()
|
||||||
|
return
|
||||||
|
self._active_generation = None
|
||||||
|
generation = self._pending_generation or self._new_generation_locked()
|
||||||
|
self._pending_generation = None
|
||||||
|
self._start_generation_locked(generation)
|
||||||
|
|
||||||
|
def enqueue(
|
||||||
|
self,
|
||||||
|
command: ReplayCommand,
|
||||||
|
*,
|
||||||
|
retry_failed: bool = False,
|
||||||
|
retry_interrupted: bool = False,
|
||||||
|
) -> RecordingPreparationSnapshot:
|
||||||
|
source_command = _source_command(command)
|
||||||
|
identity = _source_identity(source_command)
|
||||||
|
with self._guard:
|
||||||
|
if self._closed:
|
||||||
|
raise RuntimeError("recording preparation manager is closed")
|
||||||
|
current = self._current_by_session.get(command.session_id)
|
||||||
|
if current is not None and current.source_identity == identity:
|
||||||
|
retry_terminal = retry_failed and current.state in {"failed", "cancelled"}
|
||||||
|
retry_restart = (
|
||||||
|
retry_interrupted
|
||||||
|
and current.state == "cancelled"
|
||||||
|
and current.interrupted_by_restart
|
||||||
|
)
|
||||||
|
if not retry_terminal and not retry_restart:
|
||||||
|
return self._snapshot_locked(current)
|
||||||
|
|
||||||
|
job = _PreparationJob(
|
||||||
|
preparation_id=uuid4().hex,
|
||||||
|
source_identity=identity,
|
||||||
|
command=source_command,
|
||||||
|
)
|
||||||
|
self._current_by_session[command.session_id] = job
|
||||||
|
try:
|
||||||
|
self._intake_queue_locked().put_nowait(job)
|
||||||
|
except queue.Full as exc:
|
||||||
|
if self._current_by_session.get(command.session_id) is job:
|
||||||
|
if current is None:
|
||||||
|
self._current_by_session.pop(command.session_id, None)
|
||||||
|
else:
|
||||||
|
self._current_by_session[command.session_id] = current
|
||||||
|
raise RecordingPreparationQueueFull("recording preparation queue is full") from exc
|
||||||
|
return self._snapshot_locked(job)
|
||||||
|
|
||||||
|
def resolve_cached(
|
||||||
|
self,
|
||||||
|
command: ReplayCommand,
|
||||||
|
) -> RecordingPreparationSnapshot | None:
|
||||||
|
"""Validate a published cache and register it as a ready job."""
|
||||||
|
|
||||||
|
# When launch preparation includes camera manifests, a disk-only RRD
|
||||||
|
# is not a complete ready result. The worker must validate both parts
|
||||||
|
# in one background transaction before publishing ``ready``.
|
||||||
|
if self._ready_preparer is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
source_command = _source_command(command)
|
||||||
|
identity = _source_identity(source_command)
|
||||||
|
recording = self.materializer.get_cached(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
|
||||||
|
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"}
|
||||||
|
):
|
||||||
|
# A worker may have started between the disk lookup and this
|
||||||
|
# transaction. Let that single job publish its own result.
|
||||||
|
if current.state == "ready":
|
||||||
|
current.recording = recording
|
||||||
|
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=(),
|
||||||
|
)
|
||||||
|
self._current_by_session[command.session_id] = ready
|
||||||
|
return self._snapshot_locked(ready)
|
||||||
|
|
||||||
|
def resolve_cached_pinned(
|
||||||
|
self,
|
||||||
|
command: ReplayCommand,
|
||||||
|
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
||||||
|
"""Validate and lease a published cache without starting conversion."""
|
||||||
|
|
||||||
|
if self._ready_preparer is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
source_command = _source_command(command)
|
||||||
|
result = self.materializer.get_cached_pinned(source_command)
|
||||||
|
if result 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
|
||||||
|
recording, release = result
|
||||||
|
identity = _source_identity(source_command)
|
||||||
|
with self._guard:
|
||||||
|
current = self._current_by_session.get(command.session_id)
|
||||||
|
if (
|
||||||
|
current is None
|
||||||
|
or current.source_identity != identity
|
||||||
|
or current.state not in ACTIVE_PREPARATION_STATES | {"ready"}
|
||||||
|
):
|
||||||
|
current = _PreparationJob(
|
||||||
|
preparation_id=uuid4().hex,
|
||||||
|
source_identity=identity,
|
||||||
|
command=source_command,
|
||||||
|
state="ready",
|
||||||
|
progress=1.0,
|
||||||
|
recording=recording,
|
||||||
|
recorded_media=(),
|
||||||
|
)
|
||||||
|
self._current_by_session[command.session_id] = current
|
||||||
|
snapshot = self._snapshot_locked(current)
|
||||||
|
return snapshot, release
|
||||||
|
|
||||||
|
def status(self, session_id: str) -> RecordingPreparationSnapshot | None:
|
||||||
|
with self._guard:
|
||||||
|
job = self._current_by_session.get(session_id)
|
||||||
|
if (
|
||||||
|
job is not None
|
||||||
|
and job.state == "ready"
|
||||||
|
and (
|
||||||
|
job.recording is None
|
||||||
|
or not self.materializer.is_recording_available(job.recording)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self._current_by_session.pop(session_id, None)
|
||||||
|
return None
|
||||||
|
return None if job is None else self._snapshot_locked(job)
|
||||||
|
|
||||||
|
def reserve_cached(
|
||||||
|
self,
|
||||||
|
command: ReplayCommand,
|
||||||
|
*,
|
||||||
|
lease_seconds: float = 120.0,
|
||||||
|
) -> RecordingPreparationSnapshot | None:
|
||||||
|
"""Pin a ready artifact across the launch-document to file-GET gap."""
|
||||||
|
|
||||||
|
if lease_seconds <= 0:
|
||||||
|
raise ValueError("recording launch lease must be positive")
|
||||||
|
pinned = self.resolve_cached_pinned(command)
|
||||||
|
if pinned is None:
|
||||||
|
return None
|
||||||
|
snapshot, release = pinned
|
||||||
|
timer = threading.Timer(lease_seconds, release)
|
||||||
|
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def pin_ready(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
preparation_id: str | None = None,
|
||||||
|
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
||||||
|
"""Cheaply lease the exact already-validated ready generation."""
|
||||||
|
|
||||||
|
with self._guard:
|
||||||
|
job = self._current_by_session.get(session_id)
|
||||||
|
if (
|
||||||
|
job is None
|
||||||
|
or job.state != "ready"
|
||||||
|
or job.recording is None
|
||||||
|
or (
|
||||||
|
preparation_id is not None
|
||||||
|
and job.preparation_id != preparation_id
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
recording = job.recording
|
||||||
|
pinned = self.materializer.pin_recording(recording)
|
||||||
|
if pinned is None:
|
||||||
|
if self._current_by_session.get(session_id) is job:
|
||||||
|
self._current_by_session.pop(session_id, None)
|
||||||
|
return None
|
||||||
|
snapshot = self._snapshot_locked(job)
|
||||||
|
return snapshot, pinned
|
||||||
|
|
||||||
|
def reserve_ready(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
preparation_id: str | None = None,
|
||||||
|
lease_seconds: float = 120.0,
|
||||||
|
) -> RecordingPreparationSnapshot | None:
|
||||||
|
"""Hold a cheap launch lease without reopening or hashing the cache."""
|
||||||
|
|
||||||
|
if lease_seconds <= 0:
|
||||||
|
raise ValueError("recording launch lease must be positive")
|
||||||
|
pinned = self.pin_ready(session_id, preparation_id=preparation_id)
|
||||||
|
if pinned is None:
|
||||||
|
return None
|
||||||
|
snapshot, release = pinned
|
||||||
|
timer = threading.Timer(lease_seconds, release)
|
||||||
|
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
|
||||||
|
with self._guard:
|
||||||
|
job = self._current_by_session.get(session_id)
|
||||||
|
if (
|
||||||
|
job is None
|
||||||
|
or job.state not in ACTIVE_PREPARATION_STATES
|
||||||
|
or (preparation_id is not None and job.preparation_id != preparation_id)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
job.cancelled_by_operator = True
|
||||||
|
job.cancel_event.set()
|
||||||
|
if job.state == "queued":
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close(self, *, timeout: float = 5.0) -> None:
|
||||||
|
with self._guard:
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
for job in self._current_by_session.values():
|
||||||
|
if job.state in ACTIVE_PREPARATION_STATES:
|
||||||
|
job.interrupted_by_restart = not job.cancelled_by_operator
|
||||||
|
job.cancel_event.set()
|
||||||
|
if job.state == "queued":
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
active = self._active_generation
|
||||||
|
if active is not None:
|
||||||
|
active.stop_event.set()
|
||||||
|
pending = self._pending_generation
|
||||||
|
if pending is not None:
|
||||||
|
pending.stop_event.set()
|
||||||
|
self._pending_generation = None
|
||||||
|
worker = None if active is None else active.worker
|
||||||
|
if worker is not None:
|
||||||
|
worker.join(timeout=max(0.0, timeout))
|
||||||
|
|
||||||
|
def _run_generation(self, generation: _WorkerGeneration) -> None:
|
||||||
|
work_queue = generation.work_queue
|
||||||
|
try:
|
||||||
|
while not generation.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
job = work_queue.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if job.state == "cancelled" or job.cancel_event.is_set():
|
||||||
|
with self._guard:
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
continue
|
||||||
|
with self._guard:
|
||||||
|
self._transition_locked(job, "validating", 0.05)
|
||||||
|
try:
|
||||||
|
recording = self.materializer.materialize(
|
||||||
|
job.command,
|
||||||
|
progress_callback=partial(self._progress, job),
|
||||||
|
cancel_event=job.cancel_event,
|
||||||
|
)
|
||||||
|
except RecordingMaterializationCancelled:
|
||||||
|
with self._guard:
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
except Exception:
|
||||||
|
# The public status intentionally does not expose paths,
|
||||||
|
# broker payloads or exporter internals.
|
||||||
|
with self._guard:
|
||||||
|
if job.cancel_event.is_set():
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
else:
|
||||||
|
self._transition_locked(job, "failed", job.progress)
|
||||||
|
job.error = "Не удалось подготовить запись сессии."
|
||||||
|
job.updated_at_utc = _utc_now_iso()
|
||||||
|
else:
|
||||||
|
with self._guard:
|
||||||
|
# Cancellation may race the final materializer
|
||||||
|
# return. Never publish ``ready`` after accepting a
|
||||||
|
# cancel request for this exact job.
|
||||||
|
if job.cancel_event.is_set():
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
else:
|
||||||
|
self._transition_locked(job, "finalizing", 0.95)
|
||||||
|
if job.cancel_event.is_set():
|
||||||
|
with self._guard:
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
recorded_media = (
|
||||||
|
()
|
||||||
|
if self._ready_preparer is None
|
||||||
|
else self._ready_preparer(job.command, recording)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
with self._guard:
|
||||||
|
if job.cancel_event.is_set():
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
else:
|
||||||
|
self._transition_locked(job, "failed", job.progress)
|
||||||
|
job.error = "Не удалось подготовить запись сессии."
|
||||||
|
job.updated_at_utc = _utc_now_iso()
|
||||||
|
continue
|
||||||
|
with self._guard:
|
||||||
|
if job.cancel_event.is_set():
|
||||||
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
|
else:
|
||||||
|
job.recording = recording
|
||||||
|
job.recorded_media = recorded_media
|
||||||
|
self._transition_locked(job, "ready", 1.0)
|
||||||
|
finally:
|
||||||
|
work_queue.task_done()
|
||||||
|
finally:
|
||||||
|
self._generation_exited(generation)
|
||||||
|
|
||||||
|
def _new_generation_locked(self) -> _WorkerGeneration:
|
||||||
|
self._generation_counter += 1
|
||||||
|
return _WorkerGeneration(
|
||||||
|
generation_id=self._generation_counter,
|
||||||
|
work_queue=queue.Queue(maxsize=self._queue_capacity),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_generation_locked(self, generation: _WorkerGeneration) -> None:
|
||||||
|
self._active_generation = generation
|
||||||
|
worker = threading.Thread(
|
||||||
|
target=self._run_generation,
|
||||||
|
args=(generation,),
|
||||||
|
name=f"missioncore-recording-preparation-{generation.generation_id}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
generation.worker = worker
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
def _generation_exited(self, generation: _WorkerGeneration) -> None:
|
||||||
|
with self._guard:
|
||||||
|
if self._active_generation is not generation:
|
||||||
|
return
|
||||||
|
self._active_generation = None
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
successor = self._pending_generation or self._new_generation_locked()
|
||||||
|
self._pending_generation = None
|
||||||
|
self._start_generation_locked(successor)
|
||||||
|
|
||||||
|
def _intake_queue_locked(self) -> queue.Queue[_PreparationJob]:
|
||||||
|
pending = self._pending_generation
|
||||||
|
if pending is not None:
|
||||||
|
return pending.work_queue
|
||||||
|
active = self._active_generation
|
||||||
|
if active is None or active.stop_event.is_set():
|
||||||
|
pending = self._new_generation_locked()
|
||||||
|
self._pending_generation = pending
|
||||||
|
return pending.work_queue
|
||||||
|
return active.work_queue
|
||||||
|
|
||||||
|
def _progress(self, job: _PreparationJob, state: str, progress: float) -> None:
|
||||||
|
# Only the worker publishes ``ready`` after it owns the verified
|
||||||
|
# recording handle; accepting the final callback would expose a short
|
||||||
|
# ready-without-launch race to status polling.
|
||||||
|
if state not in {"validating", "exporting", "finalizing"}:
|
||||||
|
return
|
||||||
|
next_state = cast(PreparationState, state)
|
||||||
|
with self._guard:
|
||||||
|
if job.state in {"cancelled", "failed", "ready"}:
|
||||||
|
return
|
||||||
|
if _PREPARATION_PHASE[next_state] < _PREPARATION_PHASE[job.state]:
|
||||||
|
return
|
||||||
|
now = monotonic()
|
||||||
|
if (
|
||||||
|
job.state == next_state
|
||||||
|
and progress <= job.progress
|
||||||
|
and now - job.last_activity_monotonic < self._heartbeat_interval_seconds
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self._transition_locked(job, next_state, progress)
|
||||||
|
|
||||||
|
def _transition_locked(
|
||||||
|
self,
|
||||||
|
job: _PreparationJob,
|
||||||
|
state: PreparationState,
|
||||||
|
progress: float,
|
||||||
|
) -> None:
|
||||||
|
if job.state not in ACTIVE_PREPARATION_STATES and state != job.state:
|
||||||
|
return
|
||||||
|
if _PREPARATION_PHASE[state] < _PREPARATION_PHASE[job.state]:
|
||||||
|
return
|
||||||
|
job.state = state
|
||||||
|
job.progress = max(job.progress, min(1.0, max(0.0, progress)))
|
||||||
|
job.updated_at_utc = _utc_now_iso()
|
||||||
|
job.last_activity_monotonic = monotonic()
|
||||||
|
|
||||||
|
def _snapshot_locked(self, job: _PreparationJob) -> RecordingPreparationSnapshot:
|
||||||
|
return RecordingPreparationSnapshot(
|
||||||
|
preparation_id=job.preparation_id,
|
||||||
|
session_id=job.command.session_id,
|
||||||
|
state=job.state,
|
||||||
|
progress=job.progress,
|
||||||
|
updated_at_utc=job.updated_at_utc,
|
||||||
|
cancellable=(
|
||||||
|
job.state == "queued"
|
||||||
|
or (
|
||||||
|
job.state in {"validating", "exporting", "finalizing"}
|
||||||
|
and self.materializer.supports_cooperative_cancellation
|
||||||
|
)
|
||||||
|
),
|
||||||
|
retryable=job.state in {"failed", "cancelled"},
|
||||||
|
error=job.error,
|
||||||
|
command=job.command,
|
||||||
|
recording=job.recording,
|
||||||
|
recorded_media=job.recorded_media,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_identity(command: ReplayCommand) -> tuple[object, ...]:
|
||||||
|
"""Build a non-blocking identity; full validation belongs to the worker."""
|
||||||
|
|
||||||
|
identities: list[object] = [
|
||||||
|
command.session_id,
|
||||||
|
str(command.source_path),
|
||||||
|
command.replay_byte_length,
|
||||||
|
command.metadata_byte_length,
|
||||||
|
command.expected_source_sha256,
|
||||||
|
]
|
||||||
|
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")):
|
||||||
|
try:
|
||||||
|
value = os.lstat(path)
|
||||||
|
except OSError:
|
||||||
|
identities.extend((str(Path(path)), None, None, None, None))
|
||||||
|
else:
|
||||||
|
identities.extend(
|
||||||
|
(
|
||||||
|
str(Path(path)),
|
||||||
|
value.st_size,
|
||||||
|
value.st_mtime_ns,
|
||||||
|
value.st_ctime_ns,
|
||||||
|
value.st_ino,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(identities)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_command(command: ReplayCommand) -> ReplayCommand:
|
||||||
|
"""Remove per-viewer launch policy from a shared preparation job."""
|
||||||
|
|
||||||
|
return replace(command, speed=1.0, loop=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_iso() -> str:
|
||||||
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,715 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from k1link.artifacts import utc_now_iso
|
||||||
|
|
||||||
|
from .legacy import discover_legacy_viewer_sessions
|
||||||
|
from .models import (
|
||||||
|
LayoutConflictError,
|
||||||
|
LegacySessionCandidate,
|
||||||
|
RecordedMediaArtifact,
|
||||||
|
ReplayCommand,
|
||||||
|
SessionArtifact,
|
||||||
|
SessionDetail,
|
||||||
|
SessionIntegrityError,
|
||||||
|
SessionModality,
|
||||||
|
SessionNotFoundError,
|
||||||
|
SessionNotReplayableError,
|
||||||
|
SessionPage,
|
||||||
|
SessionSource,
|
||||||
|
SessionStatus,
|
||||||
|
SessionSummary,
|
||||||
|
WorkspaceLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
MAX_LAYOUT_BYTES = 256 * 1024
|
||||||
|
DATABASE_NAME = "mission-core.sqlite3"
|
||||||
|
|
||||||
|
SCHEMA_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS observation_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
|
||||||
|
started_at_utc TEXT,
|
||||||
|
completed_at_utc TEXT,
|
||||||
|
duration_seconds REAL,
|
||||||
|
modalities_json TEXT NOT NULL,
|
||||||
|
replayable INTEGER NOT NULL CHECK (replayable IN (0, 1)),
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
source_count INTEGER NOT NULL,
|
||||||
|
total_bytes INTEGER NOT NULL,
|
||||||
|
replay_raw_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
allowed_root TEXT NOT NULL,
|
||||||
|
session_root TEXT NOT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
updated_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS observation_sessions_recent
|
||||||
|
ON observation_sessions(started_at_utc DESC, session_id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS observation_session_artifacts (
|
||||||
|
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
|
||||||
|
artifact_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
media_type TEXT NOT NULL,
|
||||||
|
byte_length INTEGER NOT NULL,
|
||||||
|
sha256 TEXT,
|
||||||
|
integrity_status TEXT NOT NULL,
|
||||||
|
locator TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, artifact_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS observation_session_sources (
|
||||||
|
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
semantic_channel_id TEXT NOT NULL,
|
||||||
|
modality TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
seekable INTEGER NOT NULL CHECK (seekable IN (0, 1)),
|
||||||
|
artifact_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, source_id),
|
||||||
|
FOREIGN KEY (session_id, artifact_id)
|
||||||
|
REFERENCES observation_session_artifacts(session_id, artifact_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_layouts (
|
||||||
|
workspace_id TEXT PRIMARY KEY,
|
||||||
|
layout_schema_version INTEGER NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
layout_json TEXT NOT NULL,
|
||||||
|
updated_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_missioncore_data_dir(repository_root: Path) -> Path:
|
||||||
|
configured = os.environ.get("MISSIONCORE_DATA_DIR", "").strip()
|
||||||
|
if configured:
|
||||||
|
return Path(configured).expanduser().resolve()
|
||||||
|
return (repository_root.expanduser().resolve() / ".runtime" / "mission-core").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_missioncore_evidence_dir(repository_root: Path) -> Path:
|
||||||
|
"""Return the private source-of-record root for new observation sessions."""
|
||||||
|
|
||||||
|
configured = os.environ.get("MISSIONCORE_EVIDENCE_DIR", "").strip()
|
||||||
|
if configured:
|
||||||
|
return Path(configured).expanduser().resolve()
|
||||||
|
return (resolve_missioncore_data_dir(repository_root) / "evidence" / "sessions").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore:
|
||||||
|
"""SQLite catalog plus confined filesystem references for host observation sessions."""
|
||||||
|
|
||||||
|
def __init__(self, repository_root: Path, *, data_dir: Path | None = None) -> None:
|
||||||
|
self.repository_root = repository_root.expanduser().resolve()
|
||||||
|
self.data_dir = (
|
||||||
|
data_dir.expanduser().resolve()
|
||||||
|
if data_dir is not None
|
||||||
|
else resolve_missioncore_data_dir(self.repository_root)
|
||||||
|
)
|
||||||
|
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
with _ignore_os_error():
|
||||||
|
self.data_dir.chmod(0o700)
|
||||||
|
self.database_path = self.data_dir / DATABASE_NAME
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]:
|
||||||
|
allowed_root = root.expanduser().resolve()
|
||||||
|
candidates = discover_legacy_viewer_sessions(allowed_root)
|
||||||
|
imported: list[str] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
self._upsert_legacy(candidate)
|
||||||
|
imported.append(candidate.session_id)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
discovered = set(imported)
|
||||||
|
indexed = connection.execute(
|
||||||
|
"SELECT session_id FROM observation_sessions "
|
||||||
|
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?",
|
||||||
|
(str(allowed_root),),
|
||||||
|
).fetchall()
|
||||||
|
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
|
||||||
|
connection.executemany(
|
||||||
|
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||||
|
((session_id,) for session_id in stale),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return tuple(imported)
|
||||||
|
|
||||||
|
def list_recent(self, *, limit: int = 20, cursor: str | None = None) -> SessionPage:
|
||||||
|
if not 1 <= limit <= 100:
|
||||||
|
raise ValueError("limit must be within 1..100")
|
||||||
|
parameters: list[object] = []
|
||||||
|
where = ""
|
||||||
|
with self._connect() as connection:
|
||||||
|
if cursor is not None:
|
||||||
|
_validate_identifier(cursor, "session cursor")
|
||||||
|
cursor_row = connection.execute(
|
||||||
|
"SELECT started_at_utc, session_id FROM observation_sessions "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(cursor,),
|
||||||
|
).fetchone()
|
||||||
|
if cursor_row is None:
|
||||||
|
raise SessionNotFoundError("observation session cursor was not found")
|
||||||
|
where = (
|
||||||
|
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
|
||||||
|
"(COALESCE(?, ''), ?)"
|
||||||
|
)
|
||||||
|
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
|
||||||
|
parameters.append(limit + 1)
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT * FROM observation_sessions {where} " # noqa: S608 - static clause
|
||||||
|
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
selected = rows[:limit]
|
||||||
|
items = tuple(_summary_from_row(row) for row in selected)
|
||||||
|
next_cursor = items[-1].session_id if has_more and items else None
|
||||||
|
return SessionPage(items=items, next_cursor=next_cursor)
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> SessionDetail:
|
||||||
|
_validate_identifier(session_id, "session id")
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM observation_sessions WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise SessionNotFoundError("observation session was not found")
|
||||||
|
source_rows = connection.execute(
|
||||||
|
"SELECT source_id, semantic_channel_id, modality, status, seekable, artifact_id "
|
||||||
|
"FROM observation_session_sources WHERE session_id = ? ORDER BY source_id",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
artifact_rows = connection.execute(
|
||||||
|
"SELECT artifact_id, kind, media_type, byte_length, sha256, integrity_status "
|
||||||
|
"FROM observation_session_artifacts WHERE session_id = ? ORDER BY artifact_id",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
sources = tuple(
|
||||||
|
SessionSource(
|
||||||
|
source_id=source["source_id"],
|
||||||
|
semantic_channel_id=source["semantic_channel_id"],
|
||||||
|
modality=cast(SessionModality, source["modality"]),
|
||||||
|
status=source["status"],
|
||||||
|
seekable=bool(source["seekable"]),
|
||||||
|
artifact_id=source["artifact_id"],
|
||||||
|
)
|
||||||
|
for source in source_rows
|
||||||
|
)
|
||||||
|
artifacts = tuple(
|
||||||
|
SessionArtifact(
|
||||||
|
artifact_id=artifact["artifact_id"],
|
||||||
|
kind=artifact["kind"],
|
||||||
|
media_type=artifact["media_type"],
|
||||||
|
byte_length=artifact["byte_length"],
|
||||||
|
sha256=artifact["sha256"],
|
||||||
|
integrity_status=artifact["integrity_status"],
|
||||||
|
)
|
||||||
|
for artifact in artifact_rows
|
||||||
|
)
|
||||||
|
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
|
||||||
|
|
||||||
|
def prepare_replay(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
speed: float = 1.0,
|
||||||
|
loop: bool = False,
|
||||||
|
) -> ReplayCommand:
|
||||||
|
detail = self.get_session(session_id)
|
||||||
|
if not detail.summary.replayable:
|
||||||
|
raise SessionNotReplayableError("observation session has no replayable spatial source")
|
||||||
|
if not 0 <= speed <= 100:
|
||||||
|
raise ValueError("speed must be within 0..100")
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, "
|
||||||
|
"s.replay_metadata_bytes, a.locator, a.sha256 "
|
||||||
|
"FROM observation_sessions AS s "
|
||||||
|
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
|
||||||
|
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise SessionNotReplayableError("observation session replay artifact is unavailable")
|
||||||
|
source_path = _resolve_confined_artifact(
|
||||||
|
Path(row["allowed_root"]),
|
||||||
|
Path(row["session_root"]),
|
||||||
|
Path(row["locator"]),
|
||||||
|
)
|
||||||
|
return ReplayCommand(
|
||||||
|
session_id=session_id,
|
||||||
|
source_path=source_path,
|
||||||
|
allowed_root=Path(row["allowed_root"]),
|
||||||
|
session_root=Path(row["session_root"]),
|
||||||
|
replay_byte_length=int(row["replay_raw_bytes"]),
|
||||||
|
metadata_byte_length=int(row["replay_metadata_bytes"]),
|
||||||
|
expected_source_sha256=row["sha256"],
|
||||||
|
speed=float(speed),
|
||||||
|
loop=loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
|
||||||
|
"""Return confined archived-video handles without exposing device ids."""
|
||||||
|
|
||||||
|
_validate_identifier(session_id, "session id")
|
||||||
|
with self._connect() as connection:
|
||||||
|
session = connection.execute(
|
||||||
|
"SELECT allowed_root, session_root FROM observation_sessions "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if session is None:
|
||||||
|
raise SessionNotFoundError("observation session was not found")
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT DISTINCT a.artifact_id, a.locator, a.byte_length "
|
||||||
|
"FROM observation_session_artifacts AS a "
|
||||||
|
"JOIN observation_session_sources AS source "
|
||||||
|
"ON source.session_id = a.session_id AND source.artifact_id = a.artifact_id "
|
||||||
|
"WHERE a.session_id = ? AND a.kind = 'recorded-video' "
|
||||||
|
"AND source.modality = 'video' ORDER BY a.artifact_id",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
return tuple(
|
||||||
|
_recorded_media_from_row(
|
||||||
|
session_id=session_id,
|
||||||
|
allowed_root=Path(session["allowed_root"]),
|
||||||
|
session_root=Path(session["session_root"]),
|
||||||
|
artifact_id=row["artifact_id"],
|
||||||
|
locator=Path(row["locator"]),
|
||||||
|
byte_length=row["byte_length"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_recorded_media(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
artifact_id: str,
|
||||||
|
) -> RecordedMediaArtifact:
|
||||||
|
_validate_identifier(artifact_id, "recorded media artifact id")
|
||||||
|
matches = tuple(
|
||||||
|
artifact
|
||||||
|
for artifact in self.list_recorded_media(session_id)
|
||||||
|
if artifact.artifact_id == artifact_id
|
||||||
|
)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise SessionNotFoundError("recorded media source was not found")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
def get_layout(self, workspace_id: str) -> WorkspaceLayout:
|
||||||
|
_validate_identifier(workspace_id, "workspace id")
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM workspace_layouts WHERE workspace_id = ?",
|
||||||
|
(workspace_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise SessionNotFoundError("workspace layout was not found")
|
||||||
|
return _layout_from_row(row)
|
||||||
|
|
||||||
|
def save_layout(
|
||||||
|
self,
|
||||||
|
workspace_id: str,
|
||||||
|
*,
|
||||||
|
schema_version: int,
|
||||||
|
expected_revision: int,
|
||||||
|
name: str,
|
||||||
|
layout: dict[str, Any],
|
||||||
|
) -> WorkspaceLayout:
|
||||||
|
_validate_identifier(workspace_id, "workspace id")
|
||||||
|
if schema_version != 1:
|
||||||
|
raise ValueError("only workspace layout schema version 1 is supported")
|
||||||
|
if expected_revision < 0:
|
||||||
|
raise ValueError("expected revision must be non-negative")
|
||||||
|
normalized_name = name.strip()
|
||||||
|
if not 1 <= len(normalized_name) <= 160:
|
||||||
|
raise ValueError("layout name must contain 1..160 characters")
|
||||||
|
serialized = _serialize_layout(layout)
|
||||||
|
updated_at = utc_now_iso()
|
||||||
|
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
current = connection.execute(
|
||||||
|
"SELECT revision FROM workspace_layouts WHERE workspace_id = ?",
|
||||||
|
(workspace_id,),
|
||||||
|
).fetchone()
|
||||||
|
current_revision = 0 if current is None else int(current["revision"])
|
||||||
|
if current_revision != expected_revision:
|
||||||
|
connection.rollback()
|
||||||
|
raise LayoutConflictError("workspace layout revision changed")
|
||||||
|
revision = current_revision + 1
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO workspace_layouts "
|
||||||
|
"(workspace_id, layout_schema_version, revision, name, layout_json, "
|
||||||
|
"updated_at_utc) VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(workspace_id) DO UPDATE SET "
|
||||||
|
"layout_schema_version = excluded.layout_schema_version, "
|
||||||
|
"revision = excluded.revision, name = excluded.name, "
|
||||||
|
"layout_json = excluded.layout_json, updated_at_utc = excluded.updated_at_utc",
|
||||||
|
(
|
||||||
|
workspace_id,
|
||||||
|
schema_version,
|
||||||
|
revision,
|
||||||
|
normalized_name,
|
||||||
|
serialized,
|
||||||
|
updated_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return WorkspaceLayout(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
schema_version=schema_version,
|
||||||
|
revision=revision,
|
||||||
|
name=normalized_name,
|
||||||
|
layout=json.loads(serialized),
|
||||||
|
updated_at_utc=updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.executescript(SCHEMA_SQL)
|
||||||
|
columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in connection.execute("PRAGMA table_info(observation_sessions)")
|
||||||
|
}
|
||||||
|
if "replay_raw_bytes" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE observation_sessions "
|
||||||
|
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
if "replay_metadata_bytes" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE observation_sessions "
|
||||||
|
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
with _ignore_os_error():
|
||||||
|
self.database_path.chmod(0o600)
|
||||||
|
|
||||||
|
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None:
|
||||||
|
_validate_identifier(candidate.session_id, "legacy session id")
|
||||||
|
allowed_root = candidate.allowed_root.resolve()
|
||||||
|
session_root = candidate.session_root.resolve()
|
||||||
|
if not session_root.is_relative_to(allowed_root):
|
||||||
|
raise SessionIntegrityError("legacy session root escapes its allowed root")
|
||||||
|
sources = _legacy_sources(candidate)
|
||||||
|
artifacts = _legacy_artifacts(candidate, session_root)
|
||||||
|
now = utc_now_iso()
|
||||||
|
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
|
||||||
|
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT origin, allowed_root, session_root, created_at_utc "
|
||||||
|
"FROM observation_sessions WHERE session_id = ?",
|
||||||
|
(candidate.session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None and (
|
||||||
|
existing["origin"] != "legacy-viewer-live"
|
||||||
|
or Path(existing["allowed_root"]).resolve() != allowed_root
|
||||||
|
or Path(existing["session_root"]).resolve() != session_root
|
||||||
|
):
|
||||||
|
connection.rollback()
|
||||||
|
raise SessionIntegrityError("session id is already bound to another origin")
|
||||||
|
created_at = existing["created_at_utc"] if existing is not None else now
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO observation_sessions "
|
||||||
|
"(session_id, display_name, status, started_at_utc, completed_at_utc, "
|
||||||
|
"duration_seconds, modalities_json, replayable, origin, source_count, "
|
||||||
|
"total_bytes, replay_raw_bytes, replay_metadata_bytes, allowed_root, "
|
||||||
|
"session_root, created_at_utc, updated_at_utc) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(session_id) DO UPDATE SET "
|
||||||
|
"display_name = excluded.display_name, status = excluded.status, "
|
||||||
|
"started_at_utc = excluded.started_at_utc, "
|
||||||
|
"completed_at_utc = excluded.completed_at_utc, "
|
||||||
|
"duration_seconds = excluded.duration_seconds, "
|
||||||
|
"modalities_json = excluded.modalities_json, "
|
||||||
|
"replayable = excluded.replayable, source_count = excluded.source_count, "
|
||||||
|
"total_bytes = excluded.total_bytes, "
|
||||||
|
"replay_raw_bytes = excluded.replay_raw_bytes, "
|
||||||
|
"replay_metadata_bytes = excluded.replay_metadata_bytes, "
|
||||||
|
"updated_at_utc = excluded.updated_at_utc",
|
||||||
|
(
|
||||||
|
candidate.session_id,
|
||||||
|
candidate.display_name,
|
||||||
|
candidate.status,
|
||||||
|
candidate.started_at_utc,
|
||||||
|
candidate.completed_at_utc,
|
||||||
|
candidate.duration_seconds,
|
||||||
|
modalities_json,
|
||||||
|
int(candidate.replayable),
|
||||||
|
"legacy-viewer-live",
|
||||||
|
len(sources),
|
||||||
|
candidate.total_bytes,
|
||||||
|
candidate.replay_raw_byte_length,
|
||||||
|
candidate.replay_metadata_byte_length,
|
||||||
|
str(allowed_root),
|
||||||
|
str(session_root),
|
||||||
|
created_at,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM observation_session_sources WHERE session_id = ?",
|
||||||
|
(candidate.session_id,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM observation_session_artifacts WHERE session_id = ?",
|
||||||
|
(candidate.session_id,),
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO observation_session_artifacts "
|
||||||
|
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
|
||||||
|
"integrity_status, locator) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
(candidate.session_id, *artifact)
|
||||||
|
for artifact in artifacts
|
||||||
|
],
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO observation_session_sources "
|
||||||
|
"(session_id, source_id, semantic_channel_id, modality, status, seekable, "
|
||||||
|
"artifact_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
candidate.session_id,
|
||||||
|
source.source_id,
|
||||||
|
source.semantic_channel_id,
|
||||||
|
source.modality,
|
||||||
|
source.status,
|
||||||
|
int(source.seekable),
|
||||||
|
source.artifact_id,
|
||||||
|
)
|
||||||
|
for source in sources
|
||||||
|
],
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connect(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
connection = sqlite3.connect(self.database_path, timeout=5.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
connection.execute("PRAGMA synchronous = FULL")
|
||||||
|
connection.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
connection.execute("PRAGMA journal_mode = WAL")
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]:
|
||||||
|
rows: list[SessionSource] = []
|
||||||
|
if "point-cloud" in candidate.modalities:
|
||||||
|
rows.append(
|
||||||
|
SessionSource(
|
||||||
|
source_id="sensor.lidar.primary",
|
||||||
|
semantic_channel_id="spatial.point-cloud.recorded",
|
||||||
|
modality="point-cloud",
|
||||||
|
status="recorded",
|
||||||
|
seekable=candidate.replayable,
|
||||||
|
artifact_id="raw-mqtt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "trajectory" in candidate.modalities:
|
||||||
|
rows.append(
|
||||||
|
SessionSource(
|
||||||
|
source_id="spatial.trajectory",
|
||||||
|
semantic_channel_id="spatial.pose.recorded",
|
||||||
|
modality="trajectory",
|
||||||
|
status="recorded",
|
||||||
|
seekable=candidate.replayable,
|
||||||
|
artifact_id="raw-mqtt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows.extend(
|
||||||
|
SessionSource(
|
||||||
|
source_id=media.source_id,
|
||||||
|
semantic_channel_id="camera.video.recorded",
|
||||||
|
modality="video",
|
||||||
|
status="recorded",
|
||||||
|
seekable=True,
|
||||||
|
artifact_id=media.artifact_id,
|
||||||
|
)
|
||||||
|
for media in candidate.media_sources
|
||||||
|
)
|
||||||
|
if len({source.source_id for source in rows}) != len(rows):
|
||||||
|
raise SessionIntegrityError("legacy session contains duplicate source identifiers")
|
||||||
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_artifacts(
|
||||||
|
candidate: LegacySessionCandidate,
|
||||||
|
session_root: Path,
|
||||||
|
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]:
|
||||||
|
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [
|
||||||
|
(
|
||||||
|
"raw-mqtt",
|
||||||
|
"raw-transport",
|
||||||
|
"application/x-nodedc-k1mqtt",
|
||||||
|
candidate.raw_byte_length,
|
||||||
|
candidate.raw_sha256,
|
||||||
|
candidate.raw_integrity_status,
|
||||||
|
str(candidate.raw_path),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for media in candidate.media_sources:
|
||||||
|
try:
|
||||||
|
locator = media.locator.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise SessionIntegrityError("legacy video artifact is missing") from exc
|
||||||
|
if not locator.is_dir() or not locator.is_relative_to(session_root):
|
||||||
|
raise SessionIntegrityError("legacy video artifact escapes its session root")
|
||||||
|
artifacts.append(
|
||||||
|
(
|
||||||
|
media.artifact_id,
|
||||||
|
"recorded-video",
|
||||||
|
"video/mp4",
|
||||||
|
media.byte_length,
|
||||||
|
None,
|
||||||
|
"validated-structure",
|
||||||
|
str(locator),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
|
||||||
|
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
|
||||||
|
return tuple(artifacts)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
|
||||||
|
raw_modalities = json.loads(row["modalities_json"])
|
||||||
|
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
|
||||||
|
return SessionSummary(
|
||||||
|
session_id=row["session_id"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
status=cast(SessionStatus, row["status"]),
|
||||||
|
started_at_utc=row["started_at_utc"],
|
||||||
|
completed_at_utc=row["completed_at_utc"],
|
||||||
|
duration_seconds=row["duration_seconds"],
|
||||||
|
modalities=modalities,
|
||||||
|
source_count=row["source_count"],
|
||||||
|
total_bytes=row["total_bytes"],
|
||||||
|
replayable=bool(row["replayable"]),
|
||||||
|
origin=row["origin"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_from_row(row: sqlite3.Row) -> WorkspaceLayout:
|
||||||
|
payload = json.loads(row["layout_json"])
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise SessionIntegrityError("stored workspace layout is not a JSON object")
|
||||||
|
return WorkspaceLayout(
|
||||||
|
workspace_id=row["workspace_id"],
|
||||||
|
schema_version=row["layout_schema_version"],
|
||||||
|
revision=row["revision"],
|
||||||
|
name=row["name"],
|
||||||
|
layout=payload,
|
||||||
|
updated_at_utc=row["updated_at_utc"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_confined_artifact(
|
||||||
|
allowed_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
locator: Path,
|
||||||
|
) -> Path:
|
||||||
|
try:
|
||||||
|
allowed = allowed_root.resolve(strict=True)
|
||||||
|
session = session_root.resolve(strict=True)
|
||||||
|
artifact = locator.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise SessionIntegrityError("session replay artifact is missing") from exc
|
||||||
|
if (
|
||||||
|
not allowed.is_dir()
|
||||||
|
or not session.is_dir()
|
||||||
|
or not artifact.is_file()
|
||||||
|
or not session.is_relative_to(allowed)
|
||||||
|
or not artifact.is_relative_to(session)
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("session replay artifact escapes its allowed root")
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded_media_from_row(
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
allowed_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
artifact_id: str,
|
||||||
|
locator: Path,
|
||||||
|
byte_length: int,
|
||||||
|
) -> RecordedMediaArtifact:
|
||||||
|
_validate_identifier(artifact_id, "recorded media artifact id")
|
||||||
|
try:
|
||||||
|
allowed = allowed_root.resolve(strict=True)
|
||||||
|
session = session_root.resolve(strict=True)
|
||||||
|
media_root = (session / "media").resolve(strict=True)
|
||||||
|
artifact = locator.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise SessionIntegrityError("recorded media artifact is missing") from exc
|
||||||
|
if (
|
||||||
|
not allowed.is_dir()
|
||||||
|
or not session.is_dir()
|
||||||
|
or not media_root.is_dir()
|
||||||
|
or not artifact.is_dir()
|
||||||
|
or not session.is_relative_to(allowed)
|
||||||
|
or not media_root.is_relative_to(session)
|
||||||
|
or artifact.parent != media_root
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("recorded media artifact escapes its session root")
|
||||||
|
if not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 1:
|
||||||
|
raise SessionIntegrityError("recorded media artifact has an invalid byte length")
|
||||||
|
public_suffix = artifact_id.removeprefix("recorded-video-")
|
||||||
|
public_source_id = f"recorded.camera.{public_suffix}"
|
||||||
|
_validate_identifier(public_source_id, "recorded media source id")
|
||||||
|
return RecordedMediaArtifact(
|
||||||
|
session_id=session_id,
|
||||||
|
public_source_id=public_source_id,
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
source_path=artifact,
|
||||||
|
byte_length=byte_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_layout(layout: dict[str, Any]) -> str:
|
||||||
|
try:
|
||||||
|
serialized = json.dumps(layout, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("layout must be a JSON object") from exc
|
||||||
|
if len(serialized.encode("utf-8")) > MAX_LAYOUT_BYTES:
|
||||||
|
raise ValueError("layout exceeds the 256 KiB storage boundary")
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_identifier(value: str, label: str) -> None:
|
||||||
|
if not IDENTIFIER_PATTERN.fullmatch(value):
|
||||||
|
raise ValueError(f"{label} has an invalid shape")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _ignore_os_error() -> Iterator[None]:
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
@ -53,11 +53,17 @@ def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
|
||||||
epoch_ns = fallback_epoch_ns + frame.sequence - 1
|
epoch_ns = fallback_epoch_ns + frame.sequence - 1
|
||||||
monotonic_ns: int | None = None
|
monotonic_ns: int | None = None
|
||||||
if metadata_stream is not None:
|
if metadata_stream is not None:
|
||||||
epoch_ns, monotonic_ns = _read_native_timing(
|
timing = _read_native_timing(
|
||||||
metadata_stream,
|
metadata_stream,
|
||||||
expected_sequence=frame.sequence,
|
expected_sequence=frame.sequence,
|
||||||
fallback_epoch_ns=epoch_ns,
|
fallback_epoch_ns=epoch_ns,
|
||||||
)
|
)
|
||||||
|
if timing is None:
|
||||||
|
# A crash may leave one raw frame ahead of the last fully
|
||||||
|
# committed metadata line. Only the aligned prefix has a
|
||||||
|
# trustworthy source timeline and is safe to replay.
|
||||||
|
return
|
||||||
|
epoch_ns, monotonic_ns = timing
|
||||||
yield StreamMessage(
|
yield StreamMessage(
|
||||||
sequence=frame.sequence,
|
sequence=frame.sequence,
|
||||||
topic=frame.topic,
|
topic=frame.topic,
|
||||||
|
|
@ -78,15 +84,19 @@ def _read_native_timing(
|
||||||
*,
|
*,
|
||||||
expected_sequence: int,
|
expected_sequence: int,
|
||||||
fallback_epoch_ns: int,
|
fallback_epoch_ns: int,
|
||||||
) -> tuple[int, int | None]:
|
) -> tuple[int, int | None] | None:
|
||||||
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
|
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
|
||||||
if not line:
|
if not line:
|
||||||
return fallback_epoch_ns, None
|
return None
|
||||||
if len(line) > MAX_METADATA_LINE_CHARS:
|
if len(line) > MAX_METADATA_LINE_CHARS:
|
||||||
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
|
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
|
||||||
try:
|
try:
|
||||||
record = json.loads(line)
|
record = json.loads(line)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
|
if not line.endswith(("\n", "\r")):
|
||||||
|
# A non-newline final tail is the only tolerated corruption: the
|
||||||
|
# writer may have crashed between raw and metadata group commits.
|
||||||
|
return None
|
||||||
raise ReplayFormatError(
|
raise ReplayFormatError(
|
||||||
f"native metadata line {expected_sequence} is not valid JSON"
|
f"native metadata line {expected_sequence} is not valid JSON"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,595 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rerun as rr
|
||||||
|
from rerun import blueprint as rrb
|
||||||
|
|
||||||
|
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
|
||||||
|
from k1link.protocol.normalizer import normalize_k1_message
|
||||||
|
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||||
|
from k1link.viewer.rerun_bridge import (
|
||||||
|
MAX_TRAJECTORY_POSES,
|
||||||
|
TRAJECTORY_APPEND_INTERVAL_NS,
|
||||||
|
TRAJECTORY_FORCE_APPEND_NS,
|
||||||
|
TRAJECTORY_MIN_DISTANCE_METERS,
|
||||||
|
TRAJECTORY_PUBLISH_INTERVAL_NS,
|
||||||
|
RerunSceneSettings,
|
||||||
|
_parse_hex_color,
|
||||||
|
_point_colors,
|
||||||
|
)
|
||||||
|
|
||||||
|
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||||
|
SESSION_TIMELINE = "session_time"
|
||||||
|
CAPTURE_TIMELINE = "capture_time"
|
||||||
|
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||||
|
|
||||||
|
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||||
|
# blueprint update makes the update overwrite the existing scene instead of
|
||||||
|
# creating a fresh view/container (which would also reset the operator's eye
|
||||||
|
# position and layout).
|
||||||
|
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||||
|
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||||
|
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||||
|
|
||||||
|
|
||||||
|
class RrdExportSummary(TypedDict):
|
||||||
|
schema_version: int
|
||||||
|
input_path: str
|
||||||
|
output_path: str
|
||||||
|
recording_id: str
|
||||||
|
timeline: str
|
||||||
|
capture_timeline: str
|
||||||
|
source_messages: int
|
||||||
|
decoded_messages: int
|
||||||
|
point_frames: int
|
||||||
|
pose_frames: int
|
||||||
|
ignored_messages: int
|
||||||
|
points: int
|
||||||
|
trajectory_poses: int
|
||||||
|
trajectory_updates: int
|
||||||
|
session_origin_monotonic_ns: int
|
||||||
|
timeline_start_ns: int
|
||||||
|
timeline_end_ns: int
|
||||||
|
timeline_span_ns: int
|
||||||
|
first_decoded_time_ns: int
|
||||||
|
last_decoded_time_ns: int
|
||||||
|
source_sha256: str
|
||||||
|
rrd_sha256: str
|
||||||
|
rrd_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
class RrdExportError(RuntimeError):
|
||||||
|
"""A raw capture could not be converted into a complete durable RRD."""
|
||||||
|
|
||||||
|
|
||||||
|
class RrdExportCancelled(RrdExportError):
|
||||||
|
"""A background RRD export was cooperatively cancelled."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _ExportCounters:
|
||||||
|
source_messages: int = 0
|
||||||
|
point_frames: int = 0
|
||||||
|
pose_frames: int = 0
|
||||||
|
ignored_messages: int = 0
|
||||||
|
points: int = 0
|
||||||
|
first_decoded_time_ns: int | None = None
|
||||||
|
last_decoded_time_ns: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def decoded_messages(self) -> int:
|
||||||
|
return self.point_frames + self.pose_frames
|
||||||
|
|
||||||
|
def observe_decoded(self, session_time_ns: int) -> None:
|
||||||
|
if self.first_decoded_time_ns is None:
|
||||||
|
self.first_decoded_time_ns = session_time_ns
|
||||||
|
self.last_decoded_time_ns = session_time_ns
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _TrajectoryBuffer:
|
||||||
|
positions: list[tuple[float, float, float]]
|
||||||
|
last_append_time_ns: int | None = None
|
||||||
|
last_publish_time_ns: int | None = None
|
||||||
|
updates: int = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls) -> _TrajectoryBuffer:
|
||||||
|
return cls(positions=[])
|
||||||
|
|
||||||
|
def process(
|
||||||
|
self,
|
||||||
|
recording: rr.RecordingStream,
|
||||||
|
position: tuple[float, float, float],
|
||||||
|
session_time_ns: int,
|
||||||
|
) -> None:
|
||||||
|
if not self._append(position, session_time_ns):
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
self.last_publish_time_ns is not None
|
||||||
|
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self.last_publish_time_ns = session_time_ns
|
||||||
|
self.updates += 1
|
||||||
|
recording.log(
|
||||||
|
"/world/trajectory",
|
||||||
|
rr.LineStrips3D(
|
||||||
|
[list(self.positions)],
|
||||||
|
colors=[247, 248, 244, 255],
|
||||||
|
radii=rr.Radius.ui_points(2.0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
|
||||||
|
if not self.positions:
|
||||||
|
self.positions.append(position)
|
||||||
|
self.last_append_time_ns = session_time_ns
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert self.last_append_time_ns is not None
|
||||||
|
elapsed_ns = session_time_ns - self.last_append_time_ns
|
||||||
|
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
|
||||||
|
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.positions.append(position)
|
||||||
|
self.last_append_time_ns = session_time_ns
|
||||||
|
if len(self.positions) > MAX_TRAJECTORY_POSES:
|
||||||
|
last = self.positions[-1]
|
||||||
|
self.positions = self.positions[::2]
|
||||||
|
if self.positions[-1] != last:
|
||||||
|
self.positions.append(last)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def export_k1mqtt_to_rrd(
|
||||||
|
input_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
*,
|
||||||
|
cancel_event: threading.Event | None = None,
|
||||||
|
activity_callback: Callable[[], None] | None = None,
|
||||||
|
) -> RrdExportSummary:
|
||||||
|
"""Losslessly project every decodable K1 data-plane frame into one RRD.
|
||||||
|
|
||||||
|
The raw capture remains the source of record. The derived RRD uses a
|
||||||
|
recording-local duration timeline whose zero is the first raw message's
|
||||||
|
receive-monotonic timestamp. It never traverses the bounded live-preview
|
||||||
|
queue, so export throughput cannot drop point or pose frames.
|
||||||
|
|
||||||
|
The destination is replaced only after the temporary RRD has been closed,
|
||||||
|
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||||
|
leaves an existing destination artifact untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_raise_if_cancelled(cancel_event)
|
||||||
|
source = input_path.expanduser().resolve()
|
||||||
|
destination = output_path.expanduser().resolve()
|
||||||
|
_validate_paths(source, destination)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recording_id = str(uuid4())
|
||||||
|
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
|
||||||
|
source_sha256 = _sha256_file(
|
||||||
|
source,
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
activity_callback=activity_callback,
|
||||||
|
)
|
||||||
|
settings = RerunSceneSettings()
|
||||||
|
blueprint = _recorded_blueprint(settings)
|
||||||
|
recording: rr.RecordingStream | None = None
|
||||||
|
recording_closed = False
|
||||||
|
published = False
|
||||||
|
|
||||||
|
counters = _ExportCounters()
|
||||||
|
trajectory = _TrajectoryBuffer.empty()
|
||||||
|
session_origin_ns: int | None = None
|
||||||
|
previous_monotonic_ns: int | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||||
|
recording.set_sinks(
|
||||||
|
rr.FileSink(temporary, write_footer=True),
|
||||||
|
default_blueprint=blueprint,
|
||||||
|
)
|
||||||
|
_log_static_scene(recording)
|
||||||
|
_log_session_origin(recording)
|
||||||
|
|
||||||
|
for message in iter_replay_messages(source):
|
||||||
|
_raise_if_cancelled(cancel_event)
|
||||||
|
_notify_activity(activity_callback)
|
||||||
|
counters.source_messages += 1
|
||||||
|
monotonic_ns = message.received_monotonic_ns
|
||||||
|
if monotonic_ns is None:
|
||||||
|
raise RrdExportError(
|
||||||
|
"native capture metadata must provide received_monotonic_ns "
|
||||||
|
f"for message {message.sequence}"
|
||||||
|
)
|
||||||
|
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
|
||||||
|
raise RrdExportError(
|
||||||
|
f"native capture monotonic time decreases at message {message.sequence}"
|
||||||
|
)
|
||||||
|
if session_origin_ns is None:
|
||||||
|
session_origin_ns = monotonic_ns
|
||||||
|
session_time_ns = monotonic_ns - session_origin_ns
|
||||||
|
if session_time_ns > JS_MAX_SAFE_INTEGER:
|
||||||
|
raise RrdExportError(
|
||||||
|
"session duration exceeds the exact JavaScript nanosecond range"
|
||||||
|
)
|
||||||
|
previous_monotonic_ns = monotonic_ns
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded = normalize_k1_message(
|
||||||
|
message,
|
||||||
|
processing_started_monotonic_ns=monotonic_ns,
|
||||||
|
)
|
||||||
|
except NormalizationError as exc:
|
||||||
|
raise RrdExportError(
|
||||||
|
f"known K1 frame {message.sequence} failed normalization"
|
||||||
|
) from exc
|
||||||
|
if decoded is None:
|
||||||
|
counters.ignored_messages += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
_set_frame_time(
|
||||||
|
recording,
|
||||||
|
decoded.context.sequence,
|
||||||
|
session_time_ns,
|
||||||
|
decoded.context.captured_at_epoch_ns,
|
||||||
|
)
|
||||||
|
counters.observe_decoded(session_time_ns)
|
||||||
|
if isinstance(decoded, DecodedPointCloudView):
|
||||||
|
_log_points(recording, decoded, settings)
|
||||||
|
counters.point_frames += 1
|
||||||
|
counters.points += decoded.point_count
|
||||||
|
elif isinstance(decoded, DecodedPoseView):
|
||||||
|
position = (
|
||||||
|
float(decoded.position_xyz[0]),
|
||||||
|
float(decoded.position_xyz[1]),
|
||||||
|
float(decoded.position_xyz[2]),
|
||||||
|
)
|
||||||
|
_log_pose(recording, decoded, position)
|
||||||
|
trajectory.process(recording, position, session_time_ns)
|
||||||
|
counters.pose_frames += 1
|
||||||
|
else:
|
||||||
|
counters.ignored_messages += 1
|
||||||
|
|
||||||
|
if session_origin_ns is None:
|
||||||
|
raise RrdExportError("native capture contains no messages")
|
||||||
|
if counters.decoded_messages == 0:
|
||||||
|
raise RrdExportError("native capture contains no decodable point or pose frames")
|
||||||
|
assert counters.first_decoded_time_ns is not None
|
||||||
|
assert counters.last_decoded_time_ns is not None
|
||||||
|
|
||||||
|
_raise_if_cancelled(cancel_event)
|
||||||
|
recording.flush(timeout_sec=30.0)
|
||||||
|
recording.disconnect()
|
||||||
|
recording_closed = True
|
||||||
|
_raise_if_cancelled(cancel_event)
|
||||||
|
_fsync_file(temporary)
|
||||||
|
rrd_bytes = temporary.stat().st_size
|
||||||
|
if rrd_bytes <= 0:
|
||||||
|
raise RrdExportError("Rerun produced an empty recording")
|
||||||
|
rrd_sha256 = _sha256_file(
|
||||||
|
temporary,
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
activity_callback=activity_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = RrdExportSummary(
|
||||||
|
schema_version=1,
|
||||||
|
input_path=str(source),
|
||||||
|
output_path=str(destination),
|
||||||
|
recording_id=recording_id,
|
||||||
|
timeline=SESSION_TIMELINE,
|
||||||
|
capture_timeline=CAPTURE_TIMELINE,
|
||||||
|
source_messages=counters.source_messages,
|
||||||
|
decoded_messages=counters.decoded_messages,
|
||||||
|
point_frames=counters.point_frames,
|
||||||
|
pose_frames=counters.pose_frames,
|
||||||
|
ignored_messages=counters.ignored_messages,
|
||||||
|
points=counters.points,
|
||||||
|
trajectory_poses=len(trajectory.positions),
|
||||||
|
trajectory_updates=trajectory.updates,
|
||||||
|
session_origin_monotonic_ns=session_origin_ns,
|
||||||
|
timeline_start_ns=0,
|
||||||
|
# Playback completeness is defined by data actually written to
|
||||||
|
# the RRD. K1 status/heartbeat packets may continue long after the
|
||||||
|
# final point or pose frame; advertising that raw tail as the RRD
|
||||||
|
# end makes a strict browser buffering gate wait forever.
|
||||||
|
timeline_end_ns=counters.last_decoded_time_ns,
|
||||||
|
timeline_span_ns=counters.last_decoded_time_ns,
|
||||||
|
first_decoded_time_ns=counters.first_decoded_time_ns,
|
||||||
|
last_decoded_time_ns=counters.last_decoded_time_ns,
|
||||||
|
source_sha256=source_sha256,
|
||||||
|
rrd_sha256=rrd_sha256,
|
||||||
|
rrd_bytes=rrd_bytes,
|
||||||
|
)
|
||||||
|
_raise_if_cancelled(cancel_event)
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
_fsync_directory(destination.parent)
|
||||||
|
published = True
|
||||||
|
return summary
|
||||||
|
except (ReplayFormatError, OSError) as exc:
|
||||||
|
raise RrdExportError(f"RRD export failed: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if recording is not None and not recording_closed:
|
||||||
|
with suppress(BaseException):
|
||||||
|
recording.disconnect()
|
||||||
|
if not published:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_paths(source: Path, destination: Path) -> None:
|
||||||
|
if not source.is_file():
|
||||||
|
raise RrdExportError(f"raw capture does not exist: {source}")
|
||||||
|
if source.suffix.casefold() != ".k1mqtt":
|
||||||
|
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
|
||||||
|
if destination.suffix.casefold() != ".rrd":
|
||||||
|
raise RrdExportError("RRD destination must use the .rrd suffix")
|
||||||
|
if source == destination:
|
||||||
|
raise RrdExportError("raw capture and RRD destination must be different files")
|
||||||
|
try:
|
||||||
|
replay_format = detect_replay_format(source)
|
||||||
|
except ReplayFormatError as exc:
|
||||||
|
raise RrdExportError(str(exc)) from exc
|
||||||
|
if replay_format != "k1mqtt":
|
||||||
|
raise RrdExportError("RRD export accepts only native K1MQTT captures")
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded_blueprint(
|
||||||
|
settings: RerunSceneSettings,
|
||||||
|
*,
|
||||||
|
include_initial_playback_state: bool = True,
|
||||||
|
) -> rrb.Blueprint:
|
||||||
|
accumulation = max(0.0, settings.accumulation_seconds)
|
||||||
|
# An omitted range means Rerun's native latest-at query: the most recent
|
||||||
|
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
|
||||||
|
# only matches rows stamped at the cursor's exact nanosecond and therefore
|
||||||
|
# makes an ordinary recorded scan appear empty.
|
||||||
|
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||||
|
if accumulation > 0:
|
||||||
|
time_ranges = [
|
||||||
|
rr.VisibleTimeRange(
|
||||||
|
SESSION_TIMELINE,
|
||||||
|
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
|
||||||
|
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
point_visualizer = rr.Points3D.from_fields(
|
||||||
|
# A negative Radius value is Rerun's serialized representation
|
||||||
|
# for UI points. Blueprint overrides broadcast this singleton
|
||||||
|
# value across every recorded point without rewriting the store.
|
||||||
|
radii=rr.Radius.ui_points(settings.point_size),
|
||||||
|
# Recorded height/intensity/distance/RGB palettes are baked into
|
||||||
|
# each Points3D row. A uniform custom color is the one color mode
|
||||||
|
# that can be replaced safely by a singleton blueprint override.
|
||||||
|
colors=(
|
||||||
|
[_parse_hex_color(settings.custom_color)]
|
||||||
|
if settings.palette == "custom"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
).visualizer()
|
||||||
|
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||||
|
spatial_view = rrb.Spatial3DView(
|
||||||
|
origin="/world",
|
||||||
|
name="Пространственная сцена",
|
||||||
|
background=[7, 8, 10, 255],
|
||||||
|
line_grid=rrb.LineGrid3D(
|
||||||
|
visible=settings.show_grid,
|
||||||
|
color=[86, 91, 99, 110],
|
||||||
|
stroke_width=0.75,
|
||||||
|
),
|
||||||
|
overrides={
|
||||||
|
# EntityBehavior is evaluated from the blueprint store and can
|
||||||
|
# therefore hide/reveal already-recorded entities without
|
||||||
|
# rewriting the data RRD. Keep the point visualizer alongside it
|
||||||
|
# so size/color overrides remain active for the same entity.
|
||||||
|
"/world/points": [
|
||||||
|
rrb.EntityBehavior(visible=settings.show_points),
|
||||||
|
point_visualizer,
|
||||||
|
],
|
||||||
|
"/world/trajectory": rrb.EntityBehavior(
|
||||||
|
visible=settings.show_trajectory,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
# A positive window accumulates historical frames. With no
|
||||||
|
# window, latest-at deliberately keeps one current LiDAR frame.
|
||||||
|
time_ranges=time_ranges,
|
||||||
|
)
|
||||||
|
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||||
|
root_container = rrb.Tabs(spatial_view)
|
||||||
|
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||||
|
|
||||||
|
if include_initial_playback_state:
|
||||||
|
# This state is appropriate only while opening a newly exported RRD.
|
||||||
|
# Settings-only blueprint messages must not pause an already playing
|
||||||
|
# recording or mutate the host-owned panel state.
|
||||||
|
return rrb.Blueprint(
|
||||||
|
root_container,
|
||||||
|
rrb.TimePanel(
|
||||||
|
timeline=SESSION_TIMELINE,
|
||||||
|
play_state="paused",
|
||||||
|
state="hidden",
|
||||||
|
),
|
||||||
|
auto_layout=False,
|
||||||
|
auto_views=False,
|
||||||
|
collapse_panels=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return rrb.Blueprint(
|
||||||
|
root_container,
|
||||||
|
auto_layout=False,
|
||||||
|
auto_views=False,
|
||||||
|
collapse_panels=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recorded_blueprint_rrd(
|
||||||
|
settings: RerunSceneSettings,
|
||||||
|
*,
|
||||||
|
application_id: str = APPLICATION_ID,
|
||||||
|
recording_id: str,
|
||||||
|
) -> bytes:
|
||||||
|
"""Serialize a small active blueprint update for an already-open recording.
|
||||||
|
|
||||||
|
The returned RRD contains blueprint-store messages only; it never copies the
|
||||||
|
recorded data store and is therefore safe to push through a WebViewer log
|
||||||
|
channel when operator display settings change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
recording = rr.RecordingStream(
|
||||||
|
application_id,
|
||||||
|
recording_id=recording_id,
|
||||||
|
send_properties=False,
|
||||||
|
)
|
||||||
|
stream = rr.binary_stream(recording)
|
||||||
|
try:
|
||||||
|
recording.send_blueprint(
|
||||||
|
_recorded_blueprint(
|
||||||
|
settings,
|
||||||
|
include_initial_playback_state=False,
|
||||||
|
),
|
||||||
|
make_active=True,
|
||||||
|
make_default=False,
|
||||||
|
)
|
||||||
|
payload = stream.read(flush=True, flush_timeout_sec=5.0)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RrdExportError("failed to serialize recorded blueprint") from exc
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
recording.disconnect()
|
||||||
|
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
|
||||||
|
raise RrdExportError("serialized recorded blueprint is invalid")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _log_static_scene(recording: rr.RecordingStream) -> None:
|
||||||
|
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||||
|
recording.log(
|
||||||
|
"/world/sensor_pose",
|
||||||
|
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
|
||||||
|
static=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_session_origin(recording: rr.RecordingStream) -> None:
|
||||||
|
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
|
||||||
|
|
||||||
|
recording.set_time(
|
||||||
|
SESSION_TIMELINE,
|
||||||
|
duration=np.timedelta64(0, "ns"),
|
||||||
|
)
|
||||||
|
recording.log(
|
||||||
|
"/__mission_core/session_origin",
|
||||||
|
rr.AnyValues(session_origin=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_frame_time(
|
||||||
|
recording: rr.RecordingStream,
|
||||||
|
sequence: int,
|
||||||
|
session_time_ns: int,
|
||||||
|
capture_time_ns: int,
|
||||||
|
) -> None:
|
||||||
|
recording.set_time(
|
||||||
|
SESSION_TIMELINE,
|
||||||
|
duration=np.timedelta64(session_time_ns, "ns"),
|
||||||
|
)
|
||||||
|
recording.set_time(
|
||||||
|
CAPTURE_TIMELINE,
|
||||||
|
timestamp=np.datetime64(capture_time_ns, "ns"),
|
||||||
|
)
|
||||||
|
recording.set_time("message_sequence", sequence=sequence)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_points(
|
||||||
|
recording: rr.RecordingStream,
|
||||||
|
frame: DecodedPointCloudView,
|
||||||
|
settings: RerunSceneSettings,
|
||||||
|
) -> None:
|
||||||
|
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||||
|
if frame.intensities is None:
|
||||||
|
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
||||||
|
else:
|
||||||
|
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
|
||||||
|
rgb = (
|
||||||
|
None
|
||||||
|
if frame.colors_rgb is None
|
||||||
|
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||||
|
)
|
||||||
|
recording.log(
|
||||||
|
"/world/points",
|
||||||
|
rr.Points3D(
|
||||||
|
positions,
|
||||||
|
colors=_point_colors(positions, intensities, rgb, settings),
|
||||||
|
radii=rr.Radius.ui_points(settings.point_size),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_pose(
|
||||||
|
recording: rr.RecordingStream,
|
||||||
|
frame: DecodedPoseView,
|
||||||
|
position: tuple[float, float, float],
|
||||||
|
) -> None:
|
||||||
|
recording.log(
|
||||||
|
"/world/sensor_pose",
|
||||||
|
rr.Transform3D(
|
||||||
|
translation=position,
|
||||||
|
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_file(path: Path) -> None:
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
cancel_event: threading.Event | None = None,
|
||||||
|
activity_callback: Callable[[], None] | None = None,
|
||||||
|
) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
_raise_if_cancelled(cancel_event)
|
||||||
|
_notify_activity(activity_callback)
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
raise RrdExportCancelled("RRD export was cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_activity(callback: Callable[[], None] | None) -> None:
|
||||||
|
if callback is not None:
|
||||||
|
callback()
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,40 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
|
||||||
|
from k1link.sessions.legacy import discover_legacy_viewer_sessions
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:
|
||||||
|
sessions = tmp_path / "sessions"
|
||||||
|
session = sessions / "20260717T011050Z_viewer_live"
|
||||||
|
|
||||||
|
lease = ActiveSessionLease.acquire(sessions, session)
|
||||||
|
try:
|
||||||
|
session.mkdir()
|
||||||
|
capture = session / "captures" / "mqtt_live"
|
||||||
|
capture.mkdir(parents=True)
|
||||||
|
(capture / "mqtt.raw.k1mqtt").write_bytes(b"K1MQTT\x00unfinished")
|
||||||
|
candidates = discover_legacy_viewer_sessions(sessions)
|
||||||
|
assert candidates == ()
|
||||||
|
assert recover_stale_active_session_marker(sessions) is False
|
||||||
|
finally:
|
||||||
|
lease.release()
|
||||||
|
|
||||||
|
assert not (sessions / ".current_session").exists()
|
||||||
|
candidates = discover_legacy_viewer_sessions(sessions)
|
||||||
|
assert len(candidates) == 1
|
||||||
|
assert candidates[0].session_id == session.name
|
||||||
|
assert candidates[0].replayable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_recovery_removes_only_an_unlocked_stale_marker(tmp_path: Path) -> None:
|
||||||
|
sessions = tmp_path / "sessions"
|
||||||
|
sessions.mkdir()
|
||||||
|
marker = sessions / ".current_session"
|
||||||
|
marker.write_text("20260717T011050Z_viewer_live\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert recover_stale_active_session_marker(sessions) is True
|
||||||
|
assert not marker.exists()
|
||||||
|
assert recover_stale_active_session_marker(sessions) is False
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.sessions.legacy import discover_legacy_viewer_sessions
|
||||||
|
from k1link.web.camera_archive import (
|
||||||
|
CAMERA_ARCHIVE_SCHEMA,
|
||||||
|
CAMERA_COMMIT_POLICY,
|
||||||
|
CameraArchiveError,
|
||||||
|
CameraArchiveWriter,
|
||||||
|
recover_incomplete_camera_archives,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_archive_writes_canonical_segments_and_seals_summary(tmp_path: Path) -> None:
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
writer = CameraArchiveWriter(
|
||||||
|
session,
|
||||||
|
"sensor.camera.left",
|
||||||
|
7,
|
||||||
|
commit_interval_seconds=60,
|
||||||
|
commit_bytes=1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
init = b"init-segment"
|
||||||
|
first = b"first-fragment"
|
||||||
|
second = b"second-fragment"
|
||||||
|
writer.append("init", init, host_epoch_ns=100, host_monotonic_ns=200)
|
||||||
|
writer.append("media", first, host_epoch_ns=110, host_monotonic_ns=210)
|
||||||
|
writer.append("media", second, host_epoch_ns=120, host_monotonic_ns=220)
|
||||||
|
|
||||||
|
# The tuning values cannot weaken the segment RPO: every append has already
|
||||||
|
# published its fragment, index record, and interrupted crash checkpoint.
|
||||||
|
checkpoint = json.loads(writer.summary_path.read_text(encoding="utf-8"))
|
||||||
|
assert checkpoint["status"] == "interrupted"
|
||||||
|
assert checkpoint["segment_count"] == 2
|
||||||
|
assert checkpoint["commit_policy"] == CAMERA_COMMIT_POLICY
|
||||||
|
|
||||||
|
summary = writer.close()
|
||||||
|
entries = [json.loads(line) for line in writer.index_path.read_text().splitlines()]
|
||||||
|
assert writer.archive_dir.name == "epoch-7"
|
||||||
|
assert writer.init_path.read_bytes() == init
|
||||||
|
assert [path.name for path in sorted(writer.segments_dir.iterdir())] == [
|
||||||
|
"1.m4s",
|
||||||
|
"2.m4s",
|
||||||
|
]
|
||||||
|
assert [entry["kind"] for entry in entries] == ["media", "media"]
|
||||||
|
assert [entry["sequence"] for entry in entries] == [1, 2]
|
||||||
|
assert [entry["path"] for entry in entries] == [
|
||||||
|
"segments/1.m4s",
|
||||||
|
"segments/2.m4s",
|
||||||
|
]
|
||||||
|
for entry, expected in zip(entries, (first, second), strict=True):
|
||||||
|
payload = (writer.archive_dir / entry["path"]).read_bytes()
|
||||||
|
assert payload == expected
|
||||||
|
assert entry["length"] == len(expected)
|
||||||
|
assert entry["sha256"] == hashlib.sha256(expected).hexdigest()
|
||||||
|
assert summary["schema_version"] == CAMERA_ARCHIVE_SCHEMA
|
||||||
|
assert summary["status"] == "complete"
|
||||||
|
assert summary["segment_count"] == 2
|
||||||
|
assert summary["entry_count"] == 2
|
||||||
|
assert summary["media_segment_count"] == 2
|
||||||
|
assert summary["valid_bytes"] == len(init) + len(first) + len(second)
|
||||||
|
assert summary["stream_sha256"] == hashlib.sha256(init + first + second).hexdigest()
|
||||||
|
assert summary["synchronization"] == "host-arrival-best-effort"
|
||||||
|
assert summary["artifacts"] == {
|
||||||
|
"init": "init.mp4",
|
||||||
|
"segments": "segments",
|
||||||
|
"index": "index.jsonl",
|
||||||
|
}
|
||||||
|
assert "192.168" not in json.dumps(summary)
|
||||||
|
assert writer.close() == summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_archive_rejects_media_without_init_and_existing_epoch(tmp_path: Path) -> None:
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
|
||||||
|
with pytest.raises(CameraArchiveError, match="before"):
|
||||||
|
writer.append("media", b"fragment")
|
||||||
|
writer.append("init", b"init")
|
||||||
|
writer.close(status="interrupted", failure_code="source-ended")
|
||||||
|
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
CameraArchiveWriter(session, "sensor.camera.right", 1)
|
||||||
|
with pytest.raises(ValueError, match="safe storage"):
|
||||||
|
CameraArchiveWriter(session, "../camera", 2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("symlink_component", ["media", "source"])
|
||||||
|
def test_camera_writer_never_follows_precreated_storage_symlinks(
|
||||||
|
tmp_path: Path,
|
||||||
|
symlink_component: str,
|
||||||
|
) -> None:
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
if symlink_component == "media":
|
||||||
|
(session / "media").symlink_to(outside, target_is_directory=True)
|
||||||
|
else:
|
||||||
|
media = session / "media"
|
||||||
|
media.mkdir()
|
||||||
|
(media / "sensor.camera.left").symlink_to(outside, target_is_directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(CameraArchiveError, match="no-follow"):
|
||||||
|
CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||||
|
|
||||||
|
assert list(outside.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_writer_keeps_directory_fds_across_path_swap(tmp_path: Path) -> None:
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||||
|
writer.append("init", b"init")
|
||||||
|
|
||||||
|
original_epoch = writer.archive_dir
|
||||||
|
moved_epoch = original_epoch.with_name("epoch-1-moved")
|
||||||
|
original_epoch.rename(moved_epoch)
|
||||||
|
outside_epoch = tmp_path / "outside-epoch"
|
||||||
|
outside_epoch.mkdir()
|
||||||
|
original_epoch.symlink_to(outside_epoch, target_is_directory=True)
|
||||||
|
|
||||||
|
original_segments = moved_epoch / "segments"
|
||||||
|
moved_segments = moved_epoch / "segments-held-by-fd"
|
||||||
|
original_segments.rename(moved_segments)
|
||||||
|
outside_segments = tmp_path / "outside-segments"
|
||||||
|
outside_segments.mkdir()
|
||||||
|
original_segments.symlink_to(outside_segments, target_is_directory=True)
|
||||||
|
|
||||||
|
writer.append("media", b"frame-after-path-swap")
|
||||||
|
summary = writer.close()
|
||||||
|
|
||||||
|
assert (moved_segments / "1.m4s").read_bytes() == b"frame-after-path-swap"
|
||||||
|
assert json.loads((moved_epoch / "summary.json").read_text(encoding="utf-8")) == summary
|
||||||
|
assert list(outside_epoch.iterdir()) == []
|
||||||
|
assert list(outside_segments.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_seals_unindexed_durable_tail_and_preserves_orphans(tmp_path: Path) -> None:
|
||||||
|
sessions_root = tmp_path / "sessions"
|
||||||
|
session = sessions_root / "20260717T011050Z_viewer_live"
|
||||||
|
session.mkdir(parents=True)
|
||||||
|
writer = CameraArchiveWriter(session, "sensor.camera.left", 3)
|
||||||
|
writer.append("init", b"init")
|
||||||
|
writer.append("media", b"frame-1", host_epoch_ns=100, host_monotonic_ns=200)
|
||||||
|
|
||||||
|
# Catalog refresh must not hash or rewrite a writer that is still active in
|
||||||
|
# this server process.
|
||||||
|
assert recover_incomplete_camera_archives(sessions_root) == ()
|
||||||
|
writer.close(status="interrupted", failure_code="synthetic-process-crash")
|
||||||
|
|
||||||
|
epoch = writer.archive_dir
|
||||||
|
writer.summary_path.unlink()
|
||||||
|
# Simulate a process dying after atomically publishing the next fragment but
|
||||||
|
# before its JSONL entry, plus a non-contiguous fragment that cannot be put on
|
||||||
|
# the trusted timeline. Recovery salvages #2 and quarantines (never deletes) #4.
|
||||||
|
(writer.segments_dir / "2.m4s").write_bytes(b"frame-2")
|
||||||
|
(writer.segments_dir / "4.m4s").write_bytes(b"orphan-frame")
|
||||||
|
|
||||||
|
recovered = recover_incomplete_camera_archives(sessions_root)
|
||||||
|
|
||||||
|
assert len(recovered) == 1
|
||||||
|
summary = recovered[0]
|
||||||
|
assert summary["status"] == "interrupted"
|
||||||
|
assert summary["failure_code"] == "server-process-interrupted"
|
||||||
|
assert summary["segment_count"] == 2
|
||||||
|
entries = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in writer.index_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
]
|
||||||
|
assert [entry["sequence"] for entry in entries] == [1, 2]
|
||||||
|
assert entries[0]["host_epoch_ns"] == 100
|
||||||
|
assert entries[1]["recovered"] is True
|
||||||
|
assert (writer.segments_dir / "2.m4s").read_bytes() == b"frame-2"
|
||||||
|
assert not (writer.segments_dir / "4.m4s").exists()
|
||||||
|
assert any(
|
||||||
|
path.read_bytes() == b"orphan-frame"
|
||||||
|
for path in (epoch / "recovery-orphans").iterdir()
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recovery output is exactly the layout consumed by legacy media discovery,
|
||||||
|
# and a second scan is an idempotent no-op.
|
||||||
|
assert recover_incomplete_camera_archives(sessions_root) == ()
|
||||||
|
candidates = discover_legacy_viewer_sessions(sessions_root)
|
||||||
|
assert len(candidates) == 1
|
||||||
|
assert [source.source_id for source in candidates[0].media_sources] == [
|
||||||
|
"sensor.camera.left"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_replaces_index_and_summary_symlinks_without_following_targets(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
sessions_root = tmp_path / "sessions"
|
||||||
|
session = sessions_root / "20260717T011051Z_viewer_live"
|
||||||
|
session.mkdir(parents=True)
|
||||||
|
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
|
||||||
|
writer.append("init", b"init")
|
||||||
|
writer.append("media", b"frame")
|
||||||
|
writer.close(status="interrupted")
|
||||||
|
|
||||||
|
outside_index = tmp_path / "outside-index"
|
||||||
|
outside_summary = tmp_path / "outside-summary"
|
||||||
|
outside_index.write_bytes(b"do-not-read-or-overwrite-index")
|
||||||
|
outside_summary.write_bytes(b"do-not-read-or-overwrite-summary")
|
||||||
|
writer.index_path.unlink()
|
||||||
|
writer.summary_path.unlink()
|
||||||
|
writer.index_path.symlink_to(outside_index)
|
||||||
|
writer.summary_path.symlink_to(outside_summary)
|
||||||
|
|
||||||
|
recovered = recover_incomplete_camera_archives(sessions_root)
|
||||||
|
|
||||||
|
assert len(recovered) == 1
|
||||||
|
assert recovered[0]["segment_count"] == 1
|
||||||
|
assert outside_index.read_bytes() == b"do-not-read-or-overwrite-index"
|
||||||
|
assert outside_summary.read_bytes() == b"do-not-read-or-overwrite-summary"
|
||||||
|
assert writer.index_path.is_symlink() is False
|
||||||
|
assert writer.summary_path.is_symlink() is False
|
||||||
|
assert json.loads(writer.index_path.read_text(encoding="utf-8"))["recovered"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_fails_closed_on_symlinked_quarantine_directory(tmp_path: Path) -> None:
|
||||||
|
sessions_root = tmp_path / "sessions"
|
||||||
|
session = sessions_root / "20260717T011052Z_viewer_live"
|
||||||
|
session.mkdir(parents=True)
|
||||||
|
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||||
|
writer.append("init", b"init")
|
||||||
|
writer.append("media", b"frame-1")
|
||||||
|
writer.close(status="interrupted")
|
||||||
|
writer.summary_path.unlink()
|
||||||
|
orphan = writer.segments_dir / "3.m4s"
|
||||||
|
orphan.write_bytes(b"orphan")
|
||||||
|
outside = tmp_path / "outside-quarantine"
|
||||||
|
outside.mkdir()
|
||||||
|
(writer.archive_dir / "recovery-orphans").symlink_to(outside, target_is_directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(CameraArchiveError, match="real directory"):
|
||||||
|
recover_incomplete_camera_archives(sessions_root)
|
||||||
|
|
||||||
|
assert list(outside.iterdir()) == []
|
||||||
|
assert orphan.read_bytes() == b"orphan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_quarantines_segment_symlink_without_reading_target(tmp_path: Path) -> None:
|
||||||
|
sessions_root = tmp_path / "sessions"
|
||||||
|
session = sessions_root / "20260717T011053Z_viewer_live"
|
||||||
|
session.mkdir(parents=True)
|
||||||
|
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||||
|
writer.append("init", b"init")
|
||||||
|
writer.append("media", b"frame-1")
|
||||||
|
writer.close(status="interrupted")
|
||||||
|
writer.summary_path.unlink()
|
||||||
|
outside = tmp_path / "outside-segment"
|
||||||
|
outside.write_bytes(b"external-evidence-must-not-be-read-or-moved")
|
||||||
|
(writer.segments_dir / "2.m4s").symlink_to(outside)
|
||||||
|
|
||||||
|
recovered = recover_incomplete_camera_archives(sessions_root)
|
||||||
|
|
||||||
|
assert len(recovered) == 1
|
||||||
|
assert recovered[0]["segment_count"] == 1
|
||||||
|
assert outside.read_bytes() == b"external-evidence-must-not-be-read-or-moved"
|
||||||
|
quarantined = list((writer.archive_dir / "recovery-orphans").glob("2.m4s*"))
|
||||||
|
assert len(quarantined) == 1
|
||||||
|
assert quarantined[0].is_symlink()
|
||||||
|
assert quarantined[0].resolve() == outside.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_lock_symlink_is_rejected_without_touching_target(tmp_path: Path) -> None:
|
||||||
|
sessions_root = tmp_path / "sessions"
|
||||||
|
sessions_root.mkdir()
|
||||||
|
outside = tmp_path / "outside-lock"
|
||||||
|
outside.write_bytes(b"external-lock-target")
|
||||||
|
(sessions_root / ".camera-recovery.lock").symlink_to(outside)
|
||||||
|
|
||||||
|
with pytest.raises(CameraArchiveError, match="lock failed no-follow"):
|
||||||
|
recover_incomplete_camera_archives(sessions_root)
|
||||||
|
|
||||||
|
assert outside.read_bytes() == b"external-lock-target"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX flock to assert worker serialization")
|
||||||
|
def test_recovery_startup_lease_serializes_another_process(tmp_path: Path) -> None:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
sessions_root = tmp_path / "sessions"
|
||||||
|
sessions_root.mkdir()
|
||||||
|
lock_path = sessions_root / ".camera-recovery.lock"
|
||||||
|
lock_path.touch(mode=0o600)
|
||||||
|
descriptor = os.open(lock_path, os.O_RDWR)
|
||||||
|
process: subprocess.Popen[str] | None = None
|
||||||
|
locked = False
|
||||||
|
try:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||||
|
locked = True
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-c",
|
||||||
|
(
|
||||||
|
"from pathlib import Path; "
|
||||||
|
"from k1link.web.camera_archive import "
|
||||||
|
"recover_incomplete_camera_archives; "
|
||||||
|
"recover_incomplete_camera_archives(Path(__import__('sys').argv[1])); "
|
||||||
|
"print('recovered')"
|
||||||
|
),
|
||||||
|
str(sessions_root),
|
||||||
|
],
|
||||||
|
cwd=Path(__file__).parents[1],
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
time.sleep(0.15)
|
||||||
|
assert process.poll() is None
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||||
|
locked = False
|
||||||
|
stdout, stderr = process.communicate(timeout=5)
|
||||||
|
assert process.returncode == 0, stderr
|
||||||
|
assert stdout.strip() == "recovered"
|
||||||
|
finally:
|
||||||
|
if locked:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||||
|
os.close(descriptor)
|
||||||
|
if process is not None and process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
|
@ -10,6 +10,7 @@ import pytest
|
||||||
from paho.mqtt.packettypes import PacketTypes
|
from paho.mqtt.packettypes import PacketTypes
|
||||||
from paho.mqtt.reasoncodes import ReasonCode
|
from paho.mqtt.reasoncodes import ReasonCode
|
||||||
|
|
||||||
|
import k1link.mqtt.capture as capture_module
|
||||||
from k1link.mqtt.capture import (
|
from k1link.mqtt.capture import (
|
||||||
FRAME_HEADER,
|
FRAME_HEADER,
|
||||||
RAW_MAGIC,
|
RAW_MAGIC,
|
||||||
|
|
@ -256,3 +257,85 @@ def test_capture_reader_rejects_invalid_or_unbounded_frames(
|
||||||
|
|
||||||
with pytest.raises(CaptureFormatError, match=message):
|
with pytest.raises(CaptureFormatError, match=message):
|
||||||
list(iter_capture_frames(path, max_payload_bytes=4))
|
list(iter_capture_frames(path, max_payload_bytes=4))
|
||||||
|
|
||||||
|
|
||||||
|
def _mqtt_message(topic: str, payload: bytes) -> mqtt.MQTTMessage:
|
||||||
|
message = mqtt.MQTTMessage(topic=topic.encode("utf-8"))
|
||||||
|
message.payload = payload
|
||||||
|
message.qos = 0
|
||||||
|
message.retain = False
|
||||||
|
message.dup = False
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_commit_fsyncs_raw_before_publishing_metadata(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
|
||||||
|
writer.open()
|
||||||
|
assert writer._raw is not None # noqa: SLF001
|
||||||
|
assert writer._metadata is not None # noqa: SLF001
|
||||||
|
raw_fd = writer._raw.fileno() # noqa: SLF001
|
||||||
|
metadata_fd = writer._metadata.fileno() # noqa: SLF001
|
||||||
|
fsync_calls: list[int] = []
|
||||||
|
real_fsync = capture_module.os.fsync
|
||||||
|
|
||||||
|
def observe_fsync(descriptor: int) -> None:
|
||||||
|
fsync_calls.append(descriptor)
|
||||||
|
real_fsync(descriptor)
|
||||||
|
|
||||||
|
monkeypatch.setattr(capture_module.os, "fsync", observe_fsync)
|
||||||
|
monkeypatch.setattr(capture_module, "GROUP_COMMIT_MAX_MESSAGES", 2)
|
||||||
|
|
||||||
|
writer.record(_mqtt_message("RealtimePath", b"one"))
|
||||||
|
assert writer.metadata_path.read_bytes() == b""
|
||||||
|
writer.record(_mqtt_message("RealtimePath", b"two"))
|
||||||
|
|
||||||
|
assert fsync_calls[:2] == [raw_fd, metadata_fd]
|
||||||
|
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 2
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_commit_timer_bounds_quiet_stream_rpo(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
|
||||||
|
writer.open()
|
||||||
|
writer.record(_mqtt_message("RealtimePath", b"one"))
|
||||||
|
assert writer.metadata_path.read_bytes() == b""
|
||||||
|
|
||||||
|
writer.maybe_commit(
|
||||||
|
writer._last_commit_monotonic # noqa: SLF001
|
||||||
|
+ capture_module.GROUP_COMMIT_INTERVAL_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 1
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_fsync_failure_never_publishes_metadata_ahead_of_raw(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
|
||||||
|
writer.open()
|
||||||
|
writer.record(_mqtt_message("RealtimePath", b"one"))
|
||||||
|
assert writer._raw is not None # noqa: SLF001
|
||||||
|
raw_fd = writer._raw.fileno() # noqa: SLF001
|
||||||
|
real_fsync = capture_module.os.fsync
|
||||||
|
|
||||||
|
def fail_raw_fsync(descriptor: int) -> None:
|
||||||
|
if descriptor == raw_fd:
|
||||||
|
raise OSError("synthetic raw fsync failure")
|
||||||
|
real_fsync(descriptor)
|
||||||
|
|
||||||
|
monkeypatch.setattr(capture_module.os, "fsync", fail_raw_fsync)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="synthetic raw fsync failure"):
|
||||||
|
writer._commit_pending() # noqa: SLF001
|
||||||
|
|
||||||
|
assert writer.metadata_path.read_bytes() == b""
|
||||||
|
# Restore durability primitive so the fixture can close normally.
|
||||||
|
monkeypatch.setattr(capture_module.os, "fsync", real_fsync)
|
||||||
|
writer.close()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import k1link.viewer.rrd_export as export_module
|
||||||
|
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||||
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||||
|
from k1link.viewer.rrd_export import (
|
||||||
|
RECORDED_POINTS_VISUALIZER_ID,
|
||||||
|
RECORDED_ROOT_CONTAINER_ID,
|
||||||
|
RECORDED_SPATIAL_VIEW_ID,
|
||||||
|
SESSION_TIMELINE,
|
||||||
|
RrdExportCancelled,
|
||||||
|
RrdExportError,
|
||||||
|
_recorded_blueprint,
|
||||||
|
export_k1mqtt_to_rrd,
|
||||||
|
recorded_blueprint_rrd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _point_payload(x: float) -> bytes:
|
||||||
|
return struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||||
|
"<fffBBBB",
|
||||||
|
x,
|
||||||
|
-2.0,
|
||||||
|
3.0,
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
30,
|
||||||
|
40,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pose_payload(x: float) -> bytes:
|
||||||
|
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_capture(
|
||||||
|
directory: Path,
|
||||||
|
frames: list[tuple[str, bytes, int]],
|
||||||
|
) -> Path:
|
||||||
|
capture = directory / "mqtt.raw.k1mqtt"
|
||||||
|
raw = bytearray(RAW_MAGIC)
|
||||||
|
metadata: list[dict[str, object]] = []
|
||||||
|
epoch_origin_ns = 1_784_124_315_000_000_000
|
||||||
|
monotonic_origin_ns = 9_000_000_000
|
||||||
|
for sequence, (topic, payload, session_time_ns) in enumerate(frames, start=1):
|
||||||
|
topic_raw = topic.encode("utf-8")
|
||||||
|
raw.extend(FRAME_HEADER.pack(len(topic_raw), len(payload)))
|
||||||
|
raw.extend(topic_raw)
|
||||||
|
raw.extend(payload)
|
||||||
|
metadata.append(
|
||||||
|
{
|
||||||
|
"record_type": "message",
|
||||||
|
"sequence": sequence,
|
||||||
|
"received_at_epoch_ns": epoch_origin_ns + session_time_ns,
|
||||||
|
"received_monotonic_ns": monotonic_origin_ns + session_time_ns,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
capture.write_bytes(raw)
|
||||||
|
(directory / "mqtt.metadata.jsonl").write_text(
|
||||||
|
"".join(json.dumps(item) + "\n" for item in metadata),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return capture
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_rrd_entity(path: Path, entity: str) -> str:
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"rerun_cli",
|
||||||
|
"rrd",
|
||||||
|
"print",
|
||||||
|
"-vvv",
|
||||||
|
"--entity",
|
||||||
|
entity,
|
||||||
|
str(path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> None:
|
||||||
|
blueprint = _recorded_blueprint(
|
||||||
|
RerunSceneSettings(
|
||||||
|
point_size=7.5,
|
||||||
|
palette="custom",
|
||||||
|
custom_color="#112233",
|
||||||
|
accumulation_seconds=18.5,
|
||||||
|
show_points=False,
|
||||||
|
show_trajectory=True,
|
||||||
|
show_grid=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
spatial_view = blueprint.root_container.contents[0]
|
||||||
|
visible_ranges = spatial_view.properties["VisibleTimeRanges"]
|
||||||
|
line_grid = spatial_view.properties["LineGrid3D"]
|
||||||
|
|
||||||
|
assert visible_ranges.ranges.as_arrow_array().to_pylist() == [
|
||||||
|
{
|
||||||
|
"timeline": SESSION_TIMELINE,
|
||||||
|
"range": {
|
||||||
|
"start": -18_500_000_000,
|
||||||
|
"end": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert line_grid.visible.as_arrow_array().to_pylist() == [False]
|
||||||
|
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
|
||||||
|
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
|
||||||
|
assert point_behavior.visible.as_arrow_array().to_pylist() == [False]
|
||||||
|
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||||
|
point_overrides = {
|
||||||
|
str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
|
||||||
|
for batch in point_visualizer.overrides
|
||||||
|
}
|
||||||
|
assert point_overrides == {
|
||||||
|
"Points3D:colors": [0x112233FF],
|
||||||
|
# Negative values are Rerun's encoding for screen-space UI points.
|
||||||
|
"Points3D:radii": [-7.5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> None:
|
||||||
|
blueprint = _recorded_blueprint(
|
||||||
|
RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)
|
||||||
|
)
|
||||||
|
spatial_view = blueprint.root_container.contents[0]
|
||||||
|
|
||||||
|
assert "VisibleTimeRanges" not in spatial_view.properties
|
||||||
|
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
|
||||||
|
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
|
||||||
|
assert point_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||||
|
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||||
|
point_components = {
|
||||||
|
str(batch.component_descriptor()) for batch in point_visualizer.overrides
|
||||||
|
}
|
||||||
|
assert point_components == {"Points3D:radii"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
|
||||||
|
first = _recorded_blueprint(
|
||||||
|
RerunSceneSettings(show_points=False, show_trajectory=True),
|
||||||
|
include_initial_playback_state=False,
|
||||||
|
)
|
||||||
|
second = _recorded_blueprint(
|
||||||
|
RerunSceneSettings(show_points=True, show_trajectory=False),
|
||||||
|
include_initial_playback_state=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not hasattr(first, "time_panel")
|
||||||
|
assert not hasattr(second, "time_panel")
|
||||||
|
assert first.root_container.id == second.root_container.id == RECORDED_ROOT_CONTAINER_ID
|
||||||
|
first_view = first.root_container.contents[0]
|
||||||
|
second_view = second.root_container.contents[0]
|
||||||
|
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
|
||||||
|
|
||||||
|
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides[
|
||||||
|
"/world/points"
|
||||||
|
]
|
||||||
|
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
|
||||||
|
"/world/points"
|
||||||
|
]
|
||||||
|
first_trajectory = first_view.visualizer_overrides["/world/trajectory"]
|
||||||
|
second_trajectory = second_view.visualizer_overrides["/world/trajectory"]
|
||||||
|
assert first_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
|
||||||
|
assert second_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
|
||||||
|
assert first_point_behavior.visible.as_arrow_array().to_pylist() == [False]
|
||||||
|
assert second_point_behavior.visible.as_arrow_array().to_pylist() == [True]
|
||||||
|
assert first_trajectory.visible.as_arrow_array().to_pylist() == [True]
|
||||||
|
assert second_trajectory.visible.as_arrow_array().to_pylist() == [False]
|
||||||
|
|
||||||
|
payload = recorded_blueprint_rrd(
|
||||||
|
RerunSceneSettings(show_points=False, show_trajectory=False),
|
||||||
|
recording_id="stable-recording",
|
||||||
|
)
|
||||||
|
assert b"PlayState" not in payload
|
||||||
|
assert b"play_state" not in payload
|
||||||
|
assert str(RECORDED_ROOT_CONTAINER_ID).encode() in payload
|
||||||
|
assert str(RECORDED_SPATIAL_VIEW_ID).encode() in payload
|
||||||
|
assert str(RECORDED_POINTS_VISUALIZER_ID).encode() in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Path) -> None:
|
||||||
|
capture = _write_capture(
|
||||||
|
tmp_path,
|
||||||
|
[
|
||||||
|
("lixel/application/report/heartbeat", b"opaque", 0),
|
||||||
|
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
|
||||||
|
("RealtimePath", _pose_payload(1.0), 600_000_000),
|
||||||
|
("RealtimePointcloud", _point_payload(2.0), 900_000_000),
|
||||||
|
("RealtimePath", _pose_payload(1.2), 1_200_000_000),
|
||||||
|
("lixel/application/report/heartbeat", b"opaque-tail", 9_500_000_000),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
output = tmp_path / "session.rrd"
|
||||||
|
|
||||||
|
summary = export_k1mqtt_to_rrd(capture, output)
|
||||||
|
|
||||||
|
assert summary["source_messages"] == 6
|
||||||
|
assert summary["decoded_messages"] == 4
|
||||||
|
assert summary["point_frames"] == 2
|
||||||
|
assert summary["pose_frames"] == 2
|
||||||
|
assert summary["ignored_messages"] == 2
|
||||||
|
assert summary["points"] == 2
|
||||||
|
assert summary["trajectory_poses"] == 2
|
||||||
|
assert summary["trajectory_updates"] == 2
|
||||||
|
assert summary["session_origin_monotonic_ns"] == 9_000_000_000
|
||||||
|
assert summary["timeline"] == "session_time"
|
||||||
|
assert summary["timeline_start_ns"] == 0
|
||||||
|
assert summary["timeline_end_ns"] == 1_200_000_000
|
||||||
|
assert summary["timeline_span_ns"] == 1_200_000_000
|
||||||
|
assert summary["first_decoded_time_ns"] == 100_000_000
|
||||||
|
assert summary["last_decoded_time_ns"] == 1_200_000_000
|
||||||
|
assert summary["source_sha256"] == _sha256(capture)
|
||||||
|
assert summary["rrd_bytes"] == output.stat().st_size
|
||||||
|
assert summary["rrd_sha256"] == _sha256(output)
|
||||||
|
assert output.stat().st_size > 0
|
||||||
|
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||||
|
|
||||||
|
origin_table = _print_rrd_entity(output, "/__mission_core/session_origin")
|
||||||
|
assert "/__mission_core/session_origin" in origin_table
|
||||||
|
assert "session_time" in origin_table
|
||||||
|
assert "P0D" in origin_table
|
||||||
|
assert "[true]" in origin_table
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_rename_failure_preserves_existing_recording(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
capture = _write_capture(
|
||||||
|
tmp_path,
|
||||||
|
[("RealtimePointcloud", _point_payload(1.0), 0)],
|
||||||
|
)
|
||||||
|
output = tmp_path / "session.rrd"
|
||||||
|
trusted = b"existing-good-recording"
|
||||||
|
output.write_bytes(trusted)
|
||||||
|
|
||||||
|
def fail_replace(_source: Path, _destination: Path) -> None:
|
||||||
|
raise OSError("synthetic rename failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(export_module.os, "replace", fail_replace)
|
||||||
|
|
||||||
|
with pytest.raises(RrdExportError, match="synthetic rename failure"):
|
||||||
|
export_k1mqtt_to_rrd(capture, output)
|
||||||
|
|
||||||
|
assert output.read_bytes() == trusted
|
||||||
|
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_monotonic_metadata_never_replaces_existing_recording(tmp_path: Path) -> None:
|
||||||
|
capture = _write_capture(
|
||||||
|
tmp_path,
|
||||||
|
[("RealtimePointcloud", _point_payload(1.0), 0)],
|
||||||
|
)
|
||||||
|
(tmp_path / "mqtt.metadata.jsonl").unlink()
|
||||||
|
output = tmp_path / "session.rrd"
|
||||||
|
output.write_bytes(b"existing-good-recording")
|
||||||
|
|
||||||
|
with pytest.raises(RrdExportError, match="received_monotonic_ns"):
|
||||||
|
export_k1mqtt_to_rrd(capture, output)
|
||||||
|
|
||||||
|
assert output.read_bytes() == b"existing-good-recording"
|
||||||
|
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_preserves_valid_prefix_before_crash_metadata_tail(tmp_path: Path) -> None:
|
||||||
|
capture = _write_capture(
|
||||||
|
tmp_path,
|
||||||
|
[
|
||||||
|
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
|
||||||
|
("RealtimePointcloud", _point_payload(2.0), 200_000_000),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
metadata_path = tmp_path / "mqtt.metadata.jsonl"
|
||||||
|
first_line = metadata_path.read_text(encoding="utf-8").splitlines(keepends=True)[0]
|
||||||
|
metadata_path.write_text(
|
||||||
|
first_line + '{"record_type":"message"',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
output = tmp_path / "session.rrd"
|
||||||
|
|
||||||
|
summary = export_k1mqtt_to_rrd(capture, output)
|
||||||
|
|
||||||
|
assert summary["source_messages"] == 1
|
||||||
|
assert summary["decoded_messages"] == 1
|
||||||
|
assert summary["point_frames"] == 1
|
||||||
|
assert summary["timeline_end_ns"] == 0
|
||||||
|
assert output.stat().st_size > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_honors_cooperative_cancellation_without_publishing(tmp_path: Path) -> None:
|
||||||
|
capture = _write_capture(
|
||||||
|
tmp_path,
|
||||||
|
[("RealtimePointcloud", _point_payload(1.0), 0)],
|
||||||
|
)
|
||||||
|
output = tmp_path / "session.rrd"
|
||||||
|
output.write_bytes(b"trusted-existing-recording")
|
||||||
|
cancelled = threading.Event()
|
||||||
|
cancelled.set()
|
||||||
|
|
||||||
|
with pytest.raises(RrdExportCancelled):
|
||||||
|
export_k1mqtt_to_rrd(capture, output, cancel_event=cancelled)
|
||||||
|
|
||||||
|
assert output.read_bytes() == b"trusted-existing-recording"
|
||||||
|
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,377 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.sessions.models import ReplayCommand
|
||||||
|
from k1link.sessions.preparation import (
|
||||||
|
RecordingPreparationSnapshot,
|
||||||
|
SessionRecordingPreparationManager,
|
||||||
|
)
|
||||||
|
from k1link.sessions.recording import RecordingMaterializationError, SessionRecordingMaterializer
|
||||||
|
|
||||||
|
|
||||||
|
def _command(root: Path, session_id: str = "20260717T100000Z_viewer_live") -> ReplayCommand:
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
source = root / "mqtt.raw.k1mqtt"
|
||||||
|
metadata = root / "mqtt.metadata.jsonl"
|
||||||
|
source.write_bytes(b"native-session-source")
|
||||||
|
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
|
||||||
|
return ReplayCommand(
|
||||||
|
session_id=session_id,
|
||||||
|
source_path=source,
|
||||||
|
allowed_root=root,
|
||||||
|
session_root=root,
|
||||||
|
replay_byte_length=source.stat().st_size,
|
||||||
|
metadata_byte_length=metadata.stat().st_size,
|
||||||
|
expected_source_sha256=None,
|
||||||
|
speed=1.0,
|
||||||
|
loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(source: Path, destination: Path, payload: bytes) -> dict[str, object]:
|
||||||
|
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": 1_000_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_state(
|
||||||
|
manager: SessionRecordingPreparationManager,
|
||||||
|
session_id: str,
|
||||||
|
states: set[str],
|
||||||
|
timeout: float = 2.0,
|
||||||
|
) -> RecordingPreparationSnapshot:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
snapshot = manager.status(session_id)
|
||||||
|
if snapshot is not None and snapshot.state in states:
|
||||||
|
return snapshot
|
||||||
|
time.sleep(0.005)
|
||||||
|
raise AssertionError(f"preparation did not reach {states}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_returns_quick_job_deduplicates_and_reports_monotonic_progress(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def exporter(
|
||||||
|
source: Path,
|
||||||
|
destination: Path,
|
||||||
|
*,
|
||||||
|
cancel_event: threading.Event | None = None,
|
||||||
|
activity_callback: Callable[[], None] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
started.set()
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
while not release.wait(timeout=0.005):
|
||||||
|
assert cancel_event is None or not cancel_event.is_set()
|
||||||
|
if activity_callback is not None:
|
||||||
|
activity_callback()
|
||||||
|
assert time.monotonic() < deadline
|
||||||
|
return _summary(source, destination, b"prepared-recording")
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
|
||||||
|
heartbeat_interval_seconds=0.01,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
before = time.monotonic()
|
||||||
|
first = manager.enqueue(command)
|
||||||
|
elapsed = time.monotonic() - before
|
||||||
|
duplicate = manager.enqueue(command)
|
||||||
|
|
||||||
|
assert elapsed < 0.1
|
||||||
|
assert duplicate.preparation_id == first.preparation_id
|
||||||
|
assert started.wait(timeout=1)
|
||||||
|
exporting = _wait_for_state(manager, command.session_id, {"exporting"})
|
||||||
|
time.sleep(0.03)
|
||||||
|
heartbeat = manager.status(command.session_id)
|
||||||
|
assert heartbeat is not None
|
||||||
|
assert heartbeat.updated_at_utc > exporting.updated_at_utc
|
||||||
|
assert heartbeat.progress >= exporting.progress
|
||||||
|
assert heartbeat.cancellable is True
|
||||||
|
release.set()
|
||||||
|
ready = _wait_for_state(manager, command.session_id, {"ready"})
|
||||||
|
|
||||||
|
assert calls == 1
|
||||||
|
assert exporting.progress <= ready.progress == 1.0
|
||||||
|
assert ready.recording is not None
|
||||||
|
assert ready.updated_at_utc.endswith("Z")
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_failure_is_retryable_and_retry_publishes_new_job(tmp_path: Path) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise OSError("simulated converter failure")
|
||||||
|
return _summary(source, destination, b"retry-recording")
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
first = manager.enqueue(command)
|
||||||
|
failed = _wait_for_state(manager, command.session_id, {"failed"})
|
||||||
|
retry = manager.enqueue(command, retry_failed=True)
|
||||||
|
ready = _wait_for_state(manager, command.session_id, {"ready"})
|
||||||
|
|
||||||
|
assert failed.retryable is True
|
||||||
|
assert failed.error == "Не удалось подготовить запись сессии."
|
||||||
|
assert retry.preparation_id != first.preparation_id
|
||||||
|
assert ready.preparation_id == retry.preparation_id
|
||||||
|
assert calls == 2
|
||||||
|
finally:
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cancels_queued_job_without_exporting_it(tmp_path: Path) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260717T100001Z_viewer_live",
|
||||||
|
)
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
started.set()
|
||||||
|
assert release.wait(timeout=2)
|
||||||
|
return _summary(source, destination, b"recording")
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
manager.enqueue(first)
|
||||||
|
assert started.wait(timeout=1)
|
||||||
|
manager.enqueue(second)
|
||||||
|
assert manager.cancel(second.session_id) is True
|
||||||
|
cancelled = _wait_for_state(manager, second.session_id, {"cancelled"})
|
||||||
|
release.set()
|
||||||
|
_wait_for_state(manager, first.session_id, {"ready"})
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
assert cancelled.cancellable is False
|
||||||
|
assert calls == 1
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_restart_across_repeated_application_lifespans(tmp_path: Path) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return _summary(source, destination, b"restartable-recording")
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
manager.enqueue(command)
|
||||||
|
_wait_for_state(manager, command.session_id, {"ready"})
|
||||||
|
manager.close()
|
||||||
|
manager.start()
|
||||||
|
|
||||||
|
resumed = manager.enqueue(command)
|
||||||
|
|
||||||
|
assert resumed.state == "ready"
|
||||||
|
assert calls == 1
|
||||||
|
finally:
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_noncooperative_exporter_has_no_fake_heartbeat_and_restart_waits_for_old_writer(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260717T100002Z_viewer_live",
|
||||||
|
)
|
||||||
|
first_started = threading.Event()
|
||||||
|
release_first = threading.Event()
|
||||||
|
active = 0
|
||||||
|
max_active = 0
|
||||||
|
calls = 0
|
||||||
|
guard = threading.Lock()
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
nonlocal active, calls, max_active
|
||||||
|
with guard:
|
||||||
|
calls += 1
|
||||||
|
active += 1
|
||||||
|
max_active = max(max_active, active)
|
||||||
|
call = calls
|
||||||
|
try:
|
||||||
|
if call == 1:
|
||||||
|
first_started.set()
|
||||||
|
assert release_first.wait(timeout=2)
|
||||||
|
return _summary(source, destination, f"recording-{call}".encode())
|
||||||
|
finally:
|
||||||
|
with guard:
|
||||||
|
active -= 1
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
|
||||||
|
heartbeat_interval_seconds=0.01,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
manager.enqueue(first)
|
||||||
|
assert first_started.wait(timeout=1)
|
||||||
|
blocked = _wait_for_state(manager, first.session_id, {"exporting"})
|
||||||
|
time.sleep(0.03)
|
||||||
|
unchanged = manager.status(first.session_id)
|
||||||
|
assert unchanged is not None
|
||||||
|
assert unchanged.updated_at_utc == blocked.updated_at_utc
|
||||||
|
assert unchanged.cancellable is False
|
||||||
|
|
||||||
|
manager.close(timeout=0.001)
|
||||||
|
manager.start()
|
||||||
|
queued = manager.enqueue(second)
|
||||||
|
assert queued.state == "queued"
|
||||||
|
time.sleep(0.03)
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
release_first.set()
|
||||||
|
ready = _wait_for_state(manager, second.session_id, {"ready"})
|
||||||
|
assert ready.recording is not None
|
||||||
|
assert calls == 2
|
||||||
|
assert max_active == 1
|
||||||
|
finally:
|
||||||
|
release_first.set()
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciler_retry_does_not_replace_operator_cancel_across_restart(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
started.set()
|
||||||
|
assert release.wait(timeout=2)
|
||||||
|
return _summary(source, destination, b"operator-cancelled")
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
original = manager.enqueue(command)
|
||||||
|
assert started.wait(timeout=1)
|
||||||
|
assert manager.cancel(
|
||||||
|
command.session_id,
|
||||||
|
preparation_id=original.preparation_id,
|
||||||
|
)
|
||||||
|
manager.close(timeout=0.001)
|
||||||
|
manager.start()
|
||||||
|
release.set()
|
||||||
|
cancelled = _wait_for_state(manager, command.session_id, {"cancelled"})
|
||||||
|
|
||||||
|
reconciled = manager.enqueue(command, retry_interrupted=True)
|
||||||
|
|
||||||
|
assert reconciled.preparation_id == cancelled.preparation_id
|
||||||
|
assert reconciled.state == "cancelled"
|
||||||
|
assert calls == 1
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_preparation_command_ignores_per_request_playback_policy(tmp_path: Path) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
started.set()
|
||||||
|
assert release.wait(timeout=2)
|
||||||
|
return _summary(source, destination, b"recording")
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
first = manager.enqueue(replace(command, speed=2.0, loop=True))
|
||||||
|
assert started.wait(timeout=1)
|
||||||
|
duplicate = manager.enqueue(replace(command, speed=7.0, loop=False))
|
||||||
|
|
||||||
|
assert duplicate.preparation_id == first.preparation_id
|
||||||
|
assert duplicate.command.speed == 1.0
|
||||||
|
assert duplicate.command.loop is False
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260717T100003Z_viewer_live",
|
||||||
|
)
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
return _summary(source, destination, b"R" * (40 * 1024))
|
||||||
|
|
||||||
|
materializer = SessionRecordingMaterializer(
|
||||||
|
tmp_path / "private",
|
||||||
|
exporter=exporter,
|
||||||
|
cache_max_bytes=50 * 1024,
|
||||||
|
free_space_reserve_bytes=0,
|
||||||
|
)
|
||||||
|
manager = SessionRecordingPreparationManager(materializer)
|
||||||
|
try:
|
||||||
|
first_recording = materializer.materialize(first)
|
||||||
|
resolved = manager.resolve_cached(first)
|
||||||
|
assert resolved is not None and resolved.state == "ready"
|
||||||
|
reserved = manager.reserve_cached(first, lease_seconds=0.05)
|
||||||
|
assert reserved is not None and reserved.recording is not None
|
||||||
|
|
||||||
|
with pytest.raises(RecordingMaterializationError, match="cache quota"):
|
||||||
|
materializer.materialize(second)
|
||||||
|
assert first_recording.path.exists()
|
||||||
|
|
||||||
|
time.sleep(0.08)
|
||||||
|
second_recording = materializer.materialize(second)
|
||||||
|
assert second_recording.path.exists()
|
||||||
|
assert not first_recording.path.exists()
|
||||||
|
finally:
|
||||||
|
manager.close()
|
||||||
|
|
@ -0,0 +1,522 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import k1link.sessions.recording as recording_module
|
||||||
|
from k1link.sessions.models import ReplayCommand
|
||||||
|
from k1link.sessions.recording import (
|
||||||
|
CACHE_SCHEMA,
|
||||||
|
RERUN_RECORDING_MEDIA_TYPE,
|
||||||
|
RecordingMaterializationError,
|
||||||
|
SessionRecordingMaterializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExporter:
|
||||||
|
def __init__(self, *, delay_seconds: float = 0.0) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
self.delay_seconds = delay_seconds
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self.calls += 1
|
||||||
|
sequence = self.calls
|
||||||
|
if self.delay_seconds:
|
||||||
|
time.sleep(self.delay_seconds)
|
||||||
|
destination.write_bytes(b"RRD" + sequence.to_bytes(2, "big") + source.read_bytes())
|
||||||
|
return {
|
||||||
|
"source_sha256": _sha256(source),
|
||||||
|
"rrd_sha256": _sha256(destination),
|
||||||
|
"rrd_bytes": destination.stat().st_size,
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 2_500_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _command(tmp_path: Path) -> ReplayCommand:
|
||||||
|
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
source = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
|
source.write_bytes(b"native-source-recording")
|
||||||
|
metadata = tmp_path / "mqtt.metadata.jsonl"
|
||||||
|
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
|
||||||
|
return ReplayCommand(
|
||||||
|
session_id="20260716T205632Z_viewer_live",
|
||||||
|
source_path=source,
|
||||||
|
allowed_root=tmp_path,
|
||||||
|
session_root=tmp_path,
|
||||||
|
replay_byte_length=source.stat().st_size,
|
||||||
|
metadata_byte_length=metadata.stat().st_size,
|
||||||
|
expected_source_sha256=None,
|
||||||
|
speed=1.0,
|
||||||
|
loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Path) -> None:
|
||||||
|
command = _command(tmp_path)
|
||||||
|
exporter = FakeExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
|
||||||
|
first = materializer.materialize(command)
|
||||||
|
second = materializer.materialize(command)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert exporter.calls == 1
|
||||||
|
assert first.media_type == RERUN_RECORDING_MEDIA_TYPE
|
||||||
|
assert first.timeline == "session_time"
|
||||||
|
assert first.timeline_start_ns == 0
|
||||||
|
assert first.timeline_end_ns == 2_500_000_000
|
||||||
|
assert first.path.is_relative_to(materializer.recordings_root)
|
||||||
|
assert first.path.stat().st_mode & 0o777 == 0o600
|
||||||
|
sidecar = first.path.with_name("scene.rrd.cache.json")
|
||||||
|
assert sidecar.stat().st_mode & 0o777 == 0o600
|
||||||
|
document = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||||
|
assert document["schema_version"] == CACHE_SCHEMA
|
||||||
|
assert "source_path" not in document
|
||||||
|
assert "recording_path" not in document
|
||||||
|
assert str(tmp_path) not in sidecar.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
after_restart = SessionRecordingMaterializer(
|
||||||
|
tmp_path / "private",
|
||||||
|
exporter=exporter,
|
||||||
|
).materialize(command)
|
||||||
|
assert after_restart == first
|
||||||
|
assert exporter.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"obsolete_schema",
|
||||||
|
[
|
||||||
|
"missioncore.derived-rerun-recording-cache/v1",
|
||||||
|
"missioncore.derived-rerun-recording-cache/v2",
|
||||||
|
"missioncore.derived-rerun-recording-cache/v4",
|
||||||
|
"missioncore.derived-rerun-recording-cache/v5",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_materializer_rebuilds_incompatible_recording_cache_schema(
|
||||||
|
tmp_path: Path,
|
||||||
|
obsolete_schema: str,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path)
|
||||||
|
exporter = FakeExporter()
|
||||||
|
private_root = tmp_path / "private"
|
||||||
|
first = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
|
||||||
|
sidecar = first.path.with_name("scene.rrd.cache.json")
|
||||||
|
document = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||||
|
document["schema_version"] = obsolete_schema
|
||||||
|
sidecar.write_text(json.dumps(document), encoding="utf-8")
|
||||||
|
|
||||||
|
rebuilt = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
|
||||||
|
|
||||||
|
assert exporter.calls == 2
|
||||||
|
assert rebuilt.path == first.path
|
||||||
|
assert json.loads(sidecar.read_text(encoding="utf-8"))["schema_version"] == CACHE_SCHEMA
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_rebuild_preserves_previously_published_cache(tmp_path: Path) -> None:
|
||||||
|
command = _command(tmp_path)
|
||||||
|
exporter = FakeExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
first = materializer.materialize(command)
|
||||||
|
published_bytes = first.path.read_bytes()
|
||||||
|
sidecar = first.path.with_name("scene.rrd.cache.json")
|
||||||
|
published_sidecar = sidecar.read_bytes()
|
||||||
|
command.source_path.write_bytes(b"new-source-that-requires-rebuild")
|
||||||
|
changed = replace(command, replay_byte_length=command.source_path.stat().st_size)
|
||||||
|
|
||||||
|
def fail_export(_source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
destination.write_bytes(b"incomplete-candidate")
|
||||||
|
raise OSError("simulated export failure")
|
||||||
|
|
||||||
|
failing = SessionRecordingMaterializer(tmp_path / "private", exporter=fail_export)
|
||||||
|
with pytest.raises(RecordingMaterializationError):
|
||||||
|
failing.materialize(changed)
|
||||||
|
|
||||||
|
assert first.path.read_bytes() == published_bytes
|
||||||
|
assert sidecar.read_bytes() == published_sidecar
|
||||||
|
assert not tuple(first.path.parent.glob(".scene.*.candidate.rrd"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_materializer_rebuilds_when_source_or_recording_changes(tmp_path: Path) -> None:
|
||||||
|
command = _command(tmp_path)
|
||||||
|
exporter = FakeExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
|
||||||
|
first = materializer.materialize(command)
|
||||||
|
original_recording_stat = first.path.stat()
|
||||||
|
corrupted = bytearray(first.path.read_bytes())
|
||||||
|
corrupted[-1] ^= 0x01
|
||||||
|
first.path.write_bytes(corrupted)
|
||||||
|
os.utime(
|
||||||
|
first.path,
|
||||||
|
ns=(original_recording_stat.st_atime_ns, original_recording_stat.st_mtime_ns),
|
||||||
|
)
|
||||||
|
|
||||||
|
repaired = materializer.materialize(command)
|
||||||
|
assert exporter.calls == 2
|
||||||
|
assert repaired.sha256 == _sha256(repaired.path)
|
||||||
|
assert repaired.sha256 != hashlib.sha256(corrupted).hexdigest()
|
||||||
|
|
||||||
|
command.source_path.write_bytes(b"new-native-source")
|
||||||
|
command = replace(command, replay_byte_length=command.source_path.stat().st_size)
|
||||||
|
updated = materializer.materialize(command)
|
||||||
|
assert exporter.calls == 3
|
||||||
|
assert updated.source_sha256 == _sha256(command.source_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_materialization_exports_one_recording(tmp_path: Path) -> None:
|
||||||
|
command = _command(tmp_path)
|
||||||
|
exporter = FakeExporter(delay_seconds=0.05)
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
|
recordings = tuple(executor.map(materializer.materialize, [command] * 8))
|
||||||
|
|
||||||
|
assert exporter.calls == 1
|
||||||
|
assert len({recording.sha256 for recording in recordings}) == 1
|
||||||
|
assert len({recording.path for recording in recordings}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_materializer_never_follows_cache_symlinks_outside_private_root(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path)
|
||||||
|
exporter = FakeExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
session_cache = materializer.recordings_root / command.session_id
|
||||||
|
session_cache.symlink_to(outside, target_is_directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(RecordingMaterializationError, match="must not be a symlink"):
|
||||||
|
materializer.materialize(command)
|
||||||
|
|
||||||
|
assert list(outside.iterdir()) == []
|
||||||
|
session_cache.unlink()
|
||||||
|
session_cache.mkdir()
|
||||||
|
outside_recording = outside / "scene.rrd"
|
||||||
|
outside_recording.write_bytes(b"do-not-touch")
|
||||||
|
(session_cache / "scene.rrd").symlink_to(outside_recording)
|
||||||
|
|
||||||
|
recording = materializer.materialize(command)
|
||||||
|
|
||||||
|
assert recording.path.read_bytes().startswith(b"RRD")
|
||||||
|
assert outside_recording.read_bytes() == b"do-not-touch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materializer_revalidates_prepared_source_without_following_replaced_symlink(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
exporter = FakeExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
outside = tmp_path / "outside.k1mqtt"
|
||||||
|
outside.write_bytes(command.source_path.read_bytes())
|
||||||
|
command.source_path.unlink()
|
||||||
|
command.source_path.symlink_to(outside)
|
||||||
|
|
||||||
|
with pytest.raises(RecordingMaterializationError, match="missing or unsafe"):
|
||||||
|
materializer.materialize(command)
|
||||||
|
|
||||||
|
assert exporter.calls == 0
|
||||||
|
assert outside.read_bytes() == b"native-source-recording"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materializer_exports_only_validated_prefix_before_crash_tail(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
committed_raw_bytes = command.replay_byte_length
|
||||||
|
committed_metadata_bytes = command.metadata_byte_length
|
||||||
|
with command.source_path.open("ab") as stream:
|
||||||
|
stream.write(b"uncommitted-raw-tail")
|
||||||
|
metadata = command.source_path.with_name("mqtt.metadata.jsonl")
|
||||||
|
with metadata.open("ab") as stream:
|
||||||
|
stream.write(b'{"record_type":"message"')
|
||||||
|
observed: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
class PrefixExporter(FakeExporter):
|
||||||
|
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
observed.append(
|
||||||
|
(source.stat().st_size, source.with_name("mqtt.metadata.jsonl").stat().st_size)
|
||||||
|
)
|
||||||
|
return super().__call__(source, destination)
|
||||||
|
|
||||||
|
exporter = PrefixExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
|
||||||
|
recording = materializer.materialize(command)
|
||||||
|
|
||||||
|
assert observed == [(committed_raw_bytes, committed_metadata_bytes)]
|
||||||
|
assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
|
||||||
|
assert command.source_path.read_bytes().endswith(b"uncommitted-raw-tail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_singleflight_bounds_exports_across_different_sessions(tmp_path: Path) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260716T205633Z_viewer_live",
|
||||||
|
)
|
||||||
|
|
||||||
|
class ConcurrencyExporter(FakeExporter):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.active = 0
|
||||||
|
self.max_active = 0
|
||||||
|
|
||||||
|
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self.active += 1
|
||||||
|
self.max_active = max(self.max_active, self.active)
|
||||||
|
try:
|
||||||
|
time.sleep(0.03)
|
||||||
|
return super().__call__(source, destination)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self.active -= 1
|
||||||
|
|
||||||
|
exporter = ConcurrencyExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
tuple(executor.map(materializer.materialize, (first, second)))
|
||||||
|
|
||||||
|
assert exporter.calls == 2
|
||||||
|
assert exporter.max_active == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_process_file_lock_serializes_independent_materializer_instances(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260716T205634Z_viewer_live",
|
||||||
|
)
|
||||||
|
|
||||||
|
class ConcurrencyExporter(FakeExporter):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.active = 0
|
||||||
|
self.max_active = 0
|
||||||
|
|
||||||
|
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self.active += 1
|
||||||
|
self.max_active = max(self.max_active, self.active)
|
||||||
|
try:
|
||||||
|
time.sleep(0.03)
|
||||||
|
return super().__call__(source, destination)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self.active -= 1
|
||||||
|
|
||||||
|
exporter = ConcurrencyExporter()
|
||||||
|
private_root = tmp_path / "private"
|
||||||
|
first_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
|
||||||
|
second_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
first_future = executor.submit(first_materializer.materialize, first)
|
||||||
|
second_future = executor.submit(second_materializer.materialize, second)
|
||||||
|
assert first_future.result(timeout=2).path.exists()
|
||||||
|
assert second_future.result(timeout=2).path.exists()
|
||||||
|
|
||||||
|
assert exporter.calls == 2
|
||||||
|
assert exporter.max_active == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_scavenges_confined_crash_candidates_before_quota_check(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
private_root = tmp_path / "private"
|
||||||
|
materializer = SessionRecordingMaterializer(
|
||||||
|
private_root,
|
||||||
|
exporter=FakeExporter(),
|
||||||
|
cache_max_bytes=32 * 1024,
|
||||||
|
free_space_reserve_bytes=0,
|
||||||
|
)
|
||||||
|
session_cache = materializer.recordings_root / command.session_id
|
||||||
|
session_cache.mkdir()
|
||||||
|
stale_candidate = session_cache / ".scene.deadbeef.candidate.rrd"
|
||||||
|
stale_candidate.write_bytes(b"x" * (24 * 1024))
|
||||||
|
stale_export_temp = session_cache / "..scene.deadbeef.candidate.rrd.uuid.tmp"
|
||||||
|
stale_export_temp.write_bytes(b"x" * 1024)
|
||||||
|
stale_stage = session_cache / ".source.deadbeef.tmp"
|
||||||
|
stale_stage.mkdir()
|
||||||
|
(stale_stage / "mqtt.raw.k1mqtt").write_bytes(b"x" * 1024)
|
||||||
|
|
||||||
|
recording = materializer.materialize(command)
|
||||||
|
|
||||||
|
assert recording.path.exists()
|
||||||
|
assert not stale_candidate.exists()
|
||||||
|
assert not stale_export_temp.exists()
|
||||||
|
assert not stale_stage.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cached_recording_and_response_lease_do_not_wait_for_other_export(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260716T205633Z_viewer_live",
|
||||||
|
)
|
||||||
|
|
||||||
|
class BlockingSecondExporter(FakeExporter):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.second_started = threading.Event()
|
||||||
|
self.release_second = threading.Event()
|
||||||
|
|
||||||
|
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
next_call = self.calls + 1
|
||||||
|
if next_call == 2:
|
||||||
|
self.second_started.set()
|
||||||
|
if not self.release_second.wait(timeout=2):
|
||||||
|
raise AssertionError("test did not release the blocked export")
|
||||||
|
return super().__call__(source, destination)
|
||||||
|
|
||||||
|
exporter = BlockingSecondExporter()
|
||||||
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
||||||
|
first_recording = materializer.materialize(first)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
|
exporting = executor.submit(materializer.materialize, second)
|
||||||
|
assert exporter.second_started.wait(timeout=1)
|
||||||
|
|
||||||
|
cached = executor.submit(materializer.materialize, first).result(timeout=0.2)
|
||||||
|
pinned, release = executor.submit(
|
||||||
|
materializer.materialize_pinned,
|
||||||
|
first,
|
||||||
|
).result(timeout=0.2)
|
||||||
|
|
||||||
|
assert cached == first_recording
|
||||||
|
assert pinned == first_recording
|
||||||
|
release()
|
||||||
|
exporter.release_second.set()
|
||||||
|
assert exporting.result(timeout=1).session_id == second.session_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_quota_evicts_lru_derived_recording_without_deleting_raw(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260716T205633Z_viewer_live",
|
||||||
|
)
|
||||||
|
|
||||||
|
class SizedExporter(FakeExporter):
|
||||||
|
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self.calls += 1
|
||||||
|
destination.write_bytes(b"R" * (40 * 1024))
|
||||||
|
return {
|
||||||
|
"source_sha256": _sha256(source),
|
||||||
|
"rrd_sha256": _sha256(destination),
|
||||||
|
"rrd_bytes": destination.stat().st_size,
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
materializer = SessionRecordingMaterializer(
|
||||||
|
tmp_path / "private",
|
||||||
|
exporter=SizedExporter(),
|
||||||
|
cache_max_bytes=50 * 1024,
|
||||||
|
free_space_reserve_bytes=0,
|
||||||
|
)
|
||||||
|
first_recording = materializer.materialize(first)
|
||||||
|
first_source_bytes = first.source_path.read_bytes()
|
||||||
|
|
||||||
|
second_recording = materializer.materialize(second)
|
||||||
|
|
||||||
|
assert not first_recording.path.exists()
|
||||||
|
assert second_recording.path.exists()
|
||||||
|
assert first.source_path.read_bytes() == first_source_bytes
|
||||||
|
assert second.source_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_cache_entry_survives_eviction_until_release(tmp_path: Path) -> None:
|
||||||
|
first = _command(tmp_path / "first")
|
||||||
|
second = replace(
|
||||||
|
_command(tmp_path / "second"),
|
||||||
|
session_id="20260716T205633Z_viewer_live",
|
||||||
|
)
|
||||||
|
|
||||||
|
class SizedExporter(FakeExporter):
|
||||||
|
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self.calls += 1
|
||||||
|
destination.write_bytes(b"R" * (40 * 1024))
|
||||||
|
return {
|
||||||
|
"source_sha256": _sha256(source),
|
||||||
|
"rrd_sha256": _sha256(destination),
|
||||||
|
"rrd_bytes": destination.stat().st_size,
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
materializer = SessionRecordingMaterializer(
|
||||||
|
tmp_path / "private",
|
||||||
|
exporter=SizedExporter(),
|
||||||
|
cache_max_bytes=50 * 1024,
|
||||||
|
free_space_reserve_bytes=0,
|
||||||
|
)
|
||||||
|
first_recording, release = materializer.materialize_pinned(first)
|
||||||
|
|
||||||
|
with pytest.raises(RecordingMaterializationError, match="cache quota"):
|
||||||
|
materializer.materialize(second)
|
||||||
|
|
||||||
|
assert first_recording.path.exists()
|
||||||
|
|
||||||
|
release()
|
||||||
|
second_recording = materializer.materialize(second)
|
||||||
|
|
||||||
|
assert not first_recording.path.exists()
|
||||||
|
assert second_recording.path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_free_space_reserve_refuses_export_without_touching_raw(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
command = _command(tmp_path / "session")
|
||||||
|
source_bytes = command.source_path.read_bytes()
|
||||||
|
materializer = SessionRecordingMaterializer(
|
||||||
|
tmp_path / "private",
|
||||||
|
exporter=FakeExporter(),
|
||||||
|
cache_max_bytes=1024 * 1024,
|
||||||
|
free_space_reserve_bytes=1024,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
recording_module.shutil,
|
||||||
|
"disk_usage",
|
||||||
|
lambda _path: SimpleNamespace(free=1023),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RecordingMaterializationError, match="free-space reserve"):
|
||||||
|
materializer.materialize(command)
|
||||||
|
|
||||||
|
assert command.source_path.read_bytes() == source_bytes
|
||||||
|
|
@ -0,0 +1,573 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, iter_capture_frames
|
||||||
|
from k1link.sessions import (
|
||||||
|
LayoutConflictError,
|
||||||
|
SessionIntegrityError,
|
||||||
|
SessionNotFoundError,
|
||||||
|
SessionStore,
|
||||||
|
resolve_missioncore_evidence_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_legacy_session(
|
||||||
|
sessions_root: Path,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
created_at: str = "2026-07-16T20:56:32.699Z",
|
||||||
|
) -> Path:
|
||||||
|
session = sessions_root / session_id
|
||||||
|
capture = session / "captures" / "mqtt_live"
|
||||||
|
capture.mkdir(parents=True)
|
||||||
|
frames = [
|
||||||
|
("lixel/application/report/lio_pcl", b"point-frame"),
|
||||||
|
("lixel/application/report/lio_pose", b"pose-frame"),
|
||||||
|
]
|
||||||
|
raw = bytearray(RAW_MAGIC)
|
||||||
|
metadata: list[dict[str, object]] = []
|
||||||
|
for sequence, (topic, payload) in enumerate(frames, start=1):
|
||||||
|
topic_bytes = topic.encode()
|
||||||
|
frame_offset = len(raw)
|
||||||
|
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
|
||||||
|
raw.extend(topic_bytes)
|
||||||
|
raw.extend(payload)
|
||||||
|
metadata.append(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"record_type": "message",
|
||||||
|
"sequence": sequence,
|
||||||
|
"received_at_utc": f"2026-07-16T20:56:{31 + sequence:02d}.699Z",
|
||||||
|
"received_at_epoch_ns": 1_784_235_391_699_000_000 + sequence * 1_000_000_000,
|
||||||
|
"received_monotonic_ns": 9_000_000_000 + sequence * 1_000_000_000,
|
||||||
|
"topic": topic,
|
||||||
|
"payload_bytes": len(payload),
|
||||||
|
"raw_frame_offset": frame_offset,
|
||||||
|
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(topic_bytes),
|
||||||
|
"raw_frame_bytes": FRAME_HEADER.size + len(topic_bytes) + len(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raw_path = capture / "mqtt.raw.k1mqtt"
|
||||||
|
raw_path.write_bytes(raw)
|
||||||
|
raw_hash = hashlib.sha256(raw).hexdigest()
|
||||||
|
metadata_path = capture / "mqtt.metadata.jsonl"
|
||||||
|
metadata_path.write_text(
|
||||||
|
"".join(json.dumps(record, separators=(",", ":")) + "\n" for record in metadata),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
metadata_hash = hashlib.sha256(metadata_path.read_bytes()).hexdigest()
|
||||||
|
(capture / "mqtt.summary.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"created_at_utc": created_at,
|
||||||
|
"completed_at_utc": "2026-07-16T21:20:43.018Z",
|
||||||
|
"capture_elapsed_seconds": 1440.1,
|
||||||
|
"stop_reason": "external_stop",
|
||||||
|
"error": None,
|
||||||
|
"message_count": 2,
|
||||||
|
"raw_bytes": len(raw),
|
||||||
|
"payload_bytes": sum(len(payload) for _, payload in frames),
|
||||||
|
"topic_counts": {
|
||||||
|
"lixel/application/report/lio_pcl": 1,
|
||||||
|
"lixel/application/report/lio_pose": 1,
|
||||||
|
},
|
||||||
|
"artifact_hashes": {
|
||||||
|
"raw_sha256": raw_hash,
|
||||||
|
"metadata_jsonl_sha256": metadata_hash,
|
||||||
|
},
|
||||||
|
# This is deliberately sensitive and must not enter API DTOs.
|
||||||
|
"target_ipv4": "192.168.99.77",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(session / "manifest.redacted.json").write_text(
|
||||||
|
json.dumps({"started_at_utc": created_at, "target": "redacted"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_root_is_private_and_configurable(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
assert resolve_missioncore_evidence_dir(repository) == (
|
||||||
|
repository / ".runtime" / "mission-core" / "evidence" / "sessions"
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
configured = tmp_path / "external-evidence"
|
||||||
|
monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(configured))
|
||||||
|
assert resolve_missioncore_evidence_dir(repository) == configured.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_reconciles_sessions_removed_from_one_evidence_root(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")
|
||||||
|
|
||||||
|
assert store.import_legacy_viewer_live(sessions) == (session.name,)
|
||||||
|
shutil.rmtree(session)
|
||||||
|
|
||||||
|
assert store.import_legacy_viewer_live(sessions) == ()
|
||||||
|
assert store.list_recent().items == ()
|
||||||
|
|
||||||
|
|
||||||
|
def make_recorded_camera_source(
|
||||||
|
session: Path,
|
||||||
|
source_id: str = "sensor.camera.left",
|
||||||
|
*,
|
||||||
|
complete: bool = True,
|
||||||
|
) -> Path:
|
||||||
|
epoch = session / "media" / source_id / "epoch-1"
|
||||||
|
segments = epoch / "segments"
|
||||||
|
segments.mkdir(parents=True)
|
||||||
|
(epoch / "init.mp4").write_bytes(b"ftyp-mission-core")
|
||||||
|
(segments / "1.m4s").write_bytes(b"moof-camera-frame")
|
||||||
|
(epoch / "index.jsonl").write_text(
|
||||||
|
json.dumps({"sequence": 1, "started_at_seconds": 0.0}) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
if complete:
|
||||||
|
(epoch / "summary.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.camera-recording/v1",
|
||||||
|
"source_id": source_id,
|
||||||
|
"segment_count": 1,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return epoch
|
||||||
|
|
||||||
|
|
||||||
|
def replace_summary_with_recovery_metadata(
|
||||||
|
session: Path,
|
||||||
|
*,
|
||||||
|
corrupt_trailing_line: bool = False,
|
||||||
|
) -> None:
|
||||||
|
capture = session / "captures" / "mqtt_live"
|
||||||
|
raw_path = capture / "mqtt.raw.k1mqtt"
|
||||||
|
timestamps = (
|
||||||
|
("2026-07-16T20:56:32.699Z", 1_784_235_392_699_000_000, 10_000_000_000),
|
||||||
|
("2026-07-16T20:56:35.199Z", 1_784_235_395_199_000_000, 12_500_000_000),
|
||||||
|
)
|
||||||
|
records = []
|
||||||
|
for frame, (timestamp, epoch_ns, monotonic_ns) in zip(
|
||||||
|
iter_capture_frames(raw_path),
|
||||||
|
timestamps,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"record_type": "message",
|
||||||
|
"sequence": frame.sequence,
|
||||||
|
"received_at_utc": timestamp,
|
||||||
|
"received_at_epoch_ns": epoch_ns,
|
||||||
|
"received_monotonic_ns": monotonic_ns,
|
||||||
|
"topic": frame.topic,
|
||||||
|
"payload_bytes": frame.raw_frame_bytes
|
||||||
|
- FRAME_HEADER.size
|
||||||
|
- len(frame.topic.encode("utf-8")),
|
||||||
|
"raw_frame_offset": frame.raw_frame_offset,
|
||||||
|
"raw_payload_offset": frame.raw_payload_offset,
|
||||||
|
"raw_frame_bytes": frame.raw_frame_bytes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata = b"".join(
|
||||||
|
(json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8")
|
||||||
|
for record in records
|
||||||
|
)
|
||||||
|
if corrupt_trailing_line:
|
||||||
|
metadata += b'{"record_type":"message"'
|
||||||
|
(capture / "mqtt.metadata.jsonl").write_bytes(metadata)
|
||||||
|
(capture / "mqtt.summary.json").write_text("{corrupt", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def serialized(value: object) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
first = store.import_legacy_viewer_live(sessions)
|
||||||
|
second = store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
assert first == second == ("20260716T205632Z_viewer_live",)
|
||||||
|
page = store.list_recent()
|
||||||
|
assert len(page.items) == 1
|
||||||
|
summary = page.items[0]
|
||||||
|
assert summary.status == "ready"
|
||||||
|
assert summary.modalities == ("point-cloud", "trajectory")
|
||||||
|
assert summary.replayable is True
|
||||||
|
assert summary.source_count == 2
|
||||||
|
assert "192.168.99.77" not in serialized(page.as_dict())
|
||||||
|
assert str(repository) not in serialized(page.as_dict())
|
||||||
|
|
||||||
|
detail = store.get_session(summary.session_id)
|
||||||
|
assert [source.source_id for source in detail.sources] == [
|
||||||
|
"sensor.lidar.primary",
|
||||||
|
"spatial.trajectory",
|
||||||
|
]
|
||||||
|
assert all(source.seekable for source in detail.sources)
|
||||||
|
assert str(repository) not in serialized(detail.as_dict())
|
||||||
|
command = store.prepare_replay(summary.session_id, speed=2.0, loop=True)
|
||||||
|
assert command.source_path.name == "mqtt.raw.k1mqtt"
|
||||||
|
assert command.speed == 2.0
|
||||||
|
assert command.loop is True
|
||||||
|
|
||||||
|
with sqlite3.connect(store.database_path) as connection:
|
||||||
|
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
||||||
|
assert store.database_path.stat().st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_sessions_are_sorted_and_cursor_paginated(tmp_path: Path) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
make_legacy_session(
|
||||||
|
sessions,
|
||||||
|
"20260716T191025Z_viewer_live",
|
||||||
|
created_at="2026-07-16T19:10:25.352Z",
|
||||||
|
)
|
||||||
|
make_legacy_session(
|
||||||
|
sessions,
|
||||||
|
"20260716T205632Z_viewer_live",
|
||||||
|
created_at="2026-07-16T20:56:32.699Z",
|
||||||
|
)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
first = store.list_recent(limit=1)
|
||||||
|
assert first.items[0].session_id == "20260716T205632Z_viewer_live"
|
||||||
|
assert first.next_cursor == "20260716T205632Z_viewer_live"
|
||||||
|
second = store.list_recent(limit=1, cursor=first.next_cursor)
|
||||||
|
assert second.items[0].session_id == "20260716T191025Z_viewer_live"
|
||||||
|
assert second.next_cursor is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_import_adds_video_only_for_validated_recording_tree(tmp_path: Path) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
complete = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
incomplete = make_legacy_session(sessions, "20260716T205632Z_viewer_live_2")
|
||||||
|
make_recorded_camera_source(complete)
|
||||||
|
make_recorded_camera_source(incomplete, complete=False)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
complete_detail = store.get_session(complete.name)
|
||||||
|
assert complete_detail.summary.modalities == ("point-cloud", "trajectory", "video")
|
||||||
|
assert complete_detail.summary.source_count == 3
|
||||||
|
camera = next(
|
||||||
|
source for source in complete_detail.sources if source.source_id == "sensor.camera.left"
|
||||||
|
)
|
||||||
|
assert camera.modality == "video"
|
||||||
|
assert camera.semantic_channel_id == "camera.video.recorded"
|
||||||
|
assert camera.seekable is True
|
||||||
|
video_artifact = next(
|
||||||
|
artifact
|
||||||
|
for artifact in complete_detail.artifacts
|
||||||
|
if artifact.artifact_id == camera.artifact_id
|
||||||
|
)
|
||||||
|
assert video_artifact.kind == "recorded-video"
|
||||||
|
assert video_artifact.integrity_status == "validated-structure"
|
||||||
|
assert str(repository) not in serialized(complete_detail.as_dict())
|
||||||
|
|
||||||
|
incomplete_detail = store.get_session(incomplete.name)
|
||||||
|
assert incomplete_detail.summary.modalities == ("point-cloud", "trajectory")
|
||||||
|
assert all(source.modality != "video" for source in incomplete_detail.sources)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_capture_is_recovered_from_aligned_raw_and_metadata_prefix(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
replace_summary_with_recovery_metadata(session, corrupt_trailing_line=True)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
detail = store.get_session(session.name)
|
||||||
|
assert detail.summary.status == "interrupted"
|
||||||
|
assert detail.summary.replayable is True
|
||||||
|
assert detail.summary.modalities == ("point-cloud", "trajectory")
|
||||||
|
assert detail.summary.started_at_utc == "2026-07-16T20:56:32.699Z"
|
||||||
|
assert detail.summary.completed_at_utc == "2026-07-16T20:56:35.199Z"
|
||||||
|
assert detail.summary.duration_seconds == 2.5
|
||||||
|
assert detail.artifacts[0].integrity_status == "validated-prefix"
|
||||||
|
assert store.prepare_replay(session.name).source_path.name == "mqtt.raw.k1mqtt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_upsert_promotes_recovered_session_after_summary_is_completed(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
|
||||||
|
metadata_path = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||||
|
completed_summary = summary_path.read_text(encoding="utf-8")
|
||||||
|
completed_metadata = metadata_path.read_text(encoding="utf-8")
|
||||||
|
replace_summary_with_recovery_metadata(session)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
assert store.get_session(session.name).summary.status == "interrupted"
|
||||||
|
|
||||||
|
summary_path.write_text(completed_summary, encoding="utf-8")
|
||||||
|
metadata_path.write_text(completed_metadata, encoding="utf-8")
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
promoted = store.get_session(session.name).summary
|
||||||
|
assert promoted.status == "ready"
|
||||||
|
assert promoted.duration_seconds == 1440.1
|
||||||
|
assert promoted.replayable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_capture_does_not_trust_metadata_outside_session(tmp_path: Path) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
replace_summary_with_recovery_metadata(session)
|
||||||
|
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||||
|
outside = tmp_path / "outside.metadata.jsonl"
|
||||||
|
outside.write_bytes(metadata.read_bytes())
|
||||||
|
metadata.unlink()
|
||||||
|
metadata.symlink_to(outside)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
detail = store.get_session(session.name)
|
||||||
|
assert detail.summary.status == "failed"
|
||||||
|
assert detail.summary.replayable is False
|
||||||
|
assert detail.summary.modalities == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_capture_rejects_newline_terminated_metadata_corruption(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
replace_summary_with_recovery_metadata(session)
|
||||||
|
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||||
|
with metadata.open("ab") as stream:
|
||||||
|
stream.write(b"{corrupt\n")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
detail = store.get_session(session.name)
|
||||||
|
assert detail.summary.status == "failed"
|
||||||
|
assert detail.summary.replayable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_resolution_rejects_artifact_symlink_escape(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.import_legacy_viewer_live(sessions)
|
||||||
|
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
outside = tmp_path / "outside.k1mqtt"
|
||||||
|
outside.write_bytes(raw.read_bytes())
|
||||||
|
raw.unlink()
|
||||||
|
raw.symlink_to(outside)
|
||||||
|
|
||||||
|
with pytest.raises(SessionIntegrityError, match="escapes"):
|
||||||
|
store.prepare_replay(session.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_capture_is_not_replayable_when_declared_integrity_fails(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
corrupted = bytearray(raw.read_bytes())
|
||||||
|
corrupted[-1] ^= 0x01
|
||||||
|
raw.write_bytes(corrupted)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
detail = store.get_session(session.name)
|
||||||
|
assert detail.summary.status == "failed"
|
||||||
|
assert detail.summary.replayable is False
|
||||||
|
assert detail.summary.modalities == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_capture_is_not_replayable_when_declared_count_is_wrong(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
|
||||||
|
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
summary["message_count"] = 3
|
||||||
|
summary_path.write_text(json.dumps(summary), encoding="utf-8")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
assert store.get_session(session.name).summary.replayable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Path) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
(sessions / ".current_session").write_text(
|
||||||
|
f"sessions/{session.name}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
with pytest.raises(SessionNotFoundError):
|
||||||
|
store.get_session(session.name)
|
||||||
|
|
||||||
|
(sessions / ".current_session").unlink()
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
assert store.get_session(session.name).summary.replayable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
replace_summary_with_recovery_metadata(session)
|
||||||
|
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
committed_bytes = raw.stat().st_size
|
||||||
|
with raw.open("ab") as stream:
|
||||||
|
stream.write(FRAME_HEADER.pack(12, 100)[:7])
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
detail = store.get_session(session.name)
|
||||||
|
command = store.prepare_replay(session.name)
|
||||||
|
assert detail.summary.status == "interrupted"
|
||||||
|
assert detail.summary.replayable is True
|
||||||
|
assert command.replay_byte_length == committed_bytes
|
||||||
|
assert command.replay_byte_length < command.source_path.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_capture_rejects_non_frame_raw_tail(tmp_path: Path) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
replace_summary_with_recovery_metadata(session)
|
||||||
|
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
with raw.open("ab") as stream:
|
||||||
|
stream.write(FRAME_HEADER.pack(0, 0))
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
assert store.get_session(session.name).summary.replayable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_capture_tolerates_bounded_group_commit_raw_tail(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
replace_summary_with_recovery_metadata(session)
|
||||||
|
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||||
|
committed_bytes = raw.stat().st_size
|
||||||
|
with raw.open("ab") as stream:
|
||||||
|
for payload in (b"pending-one", b"pending-two"):
|
||||||
|
topic = b"RealtimePath"
|
||||||
|
stream.write(FRAME_HEADER.pack(len(topic), len(payload)))
|
||||||
|
stream.write(topic)
|
||||||
|
stream.write(payload)
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
command = store.prepare_replay(session.name)
|
||||||
|
assert store.get_session(session.name).summary.status == "interrupted"
|
||||||
|
assert command.replay_byte_length == committed_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_final_summary_falls_back_to_last_committed_prefix(tmp_path: Path) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
capture = session / "captures" / "mqtt_live"
|
||||||
|
metadata = capture / "mqtt.metadata.jsonl"
|
||||||
|
first_record = metadata.read_text(encoding="utf-8").splitlines(keepends=True)[0]
|
||||||
|
metadata.write_text(first_record, encoding="utf-8")
|
||||||
|
summary_path = capture / "mqtt.summary.json"
|
||||||
|
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
summary["error"] = "synthetic metadata fsync failure"
|
||||||
|
summary_path.write_text(json.dumps(summary), encoding="utf-8")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
detail = store.get_session(session.name)
|
||||||
|
assert detail.summary.status == "interrupted"
|
||||||
|
assert detail.summary.replayable is True
|
||||||
|
assert detail.summary.modalities == ("point-cloud",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_layout_save_is_atomic_and_revision_checked(tmp_path: Path) -> None:
|
||||||
|
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
first = store.save_layout(
|
||||||
|
"observation.spatial",
|
||||||
|
schema_version=1,
|
||||||
|
expected_revision=0,
|
||||||
|
name="Операторская сцена",
|
||||||
|
layout={"visible_source_ids": ["sensor.lidar.primary"], "windows": []},
|
||||||
|
)
|
||||||
|
assert first.revision == 1
|
||||||
|
assert store.get_layout("observation.spatial") == first
|
||||||
|
|
||||||
|
with pytest.raises(LayoutConflictError, match="revision changed"):
|
||||||
|
store.save_layout(
|
||||||
|
"observation.spatial",
|
||||||
|
schema_version=1,
|
||||||
|
expected_revision=0,
|
||||||
|
name="Устаревшая запись",
|
||||||
|
layout={},
|
||||||
|
)
|
||||||
|
|
||||||
|
second = store.save_layout(
|
||||||
|
"observation.spatial",
|
||||||
|
schema_version=1,
|
||||||
|
expected_revision=1,
|
||||||
|
name="Операторская сцена",
|
||||||
|
layout={"visible_source_ids": [], "windows": []},
|
||||||
|
)
|
||||||
|
assert second.revision == 2
|
||||||
|
assert store.get_layout("observation.spatial").layout["visible_source_ids"] == []
|
||||||
|
|
@ -16,6 +16,14 @@ def _write_native(path: Path, topic: str, payload: bytes) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_native(path: Path, topic: str, payload: bytes) -> None:
|
||||||
|
topic_raw = topic.encode()
|
||||||
|
with path.open("ab") as stream:
|
||||||
|
stream.write(FRAME_HEADER.pack(len(topic_raw), len(payload)))
|
||||||
|
stream.write(topic_raw)
|
||||||
|
stream.write(payload)
|
||||||
|
|
||||||
|
|
||||||
def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
|
def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
|
||||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
_write_native(capture, "lixel/application/report/lio_pose", b"pose")
|
_write_native(capture, "lixel/application/report/lio_pose", b"pose")
|
||||||
|
|
@ -39,6 +47,35 @@ def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
|
||||||
assert message.received_monotonic_ns == 456
|
assert message.received_monotonic_ns == 456
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_replay_stops_at_crash_truncated_metadata_tail(tmp_path: Path) -> None:
|
||||||
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
|
_write_native(capture, "RealtimePointcloud", b"first")
|
||||||
|
_append_native(capture, "RealtimePointcloud", b"uncommitted-tail")
|
||||||
|
first_record = {
|
||||||
|
"record_type": "message",
|
||||||
|
"sequence": 1,
|
||||||
|
"received_at_epoch_ns": 123_000_000_000,
|
||||||
|
"received_monotonic_ns": 456,
|
||||||
|
}
|
||||||
|
(tmp_path / "mqtt.metadata.jsonl").write_text(
|
||||||
|
json.dumps(first_record) + "\n" + '{"record_type":"message"',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = list(iter_replay_messages(capture))
|
||||||
|
|
||||||
|
assert [message.payload for message in messages] == [b"first"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_replay_rejects_newline_terminated_metadata_corruption(tmp_path: Path) -> None:
|
||||||
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
|
_write_native(capture, "RealtimePointcloud", b"frame")
|
||||||
|
(tmp_path / "mqtt.metadata.jsonl").write_text("{corrupt\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ReplayFormatError, match="not valid JSON"):
|
||||||
|
list(iter_replay_messages(capture))
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None:
|
def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None:
|
||||||
capture = tmp_path / "payloads.tsv"
|
capture = tmp_path / "payloads.tsv"
|
||||||
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")
|
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue