feat(sessions): add durable observation archive and replay API
This commit is contained in:
@@ -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.reasoncodes import ReasonCode
|
||||
|
||||
import k1link.mqtt.capture as capture_module
|
||||
from k1link.mqtt.capture import (
|
||||
FRAME_HEADER,
|
||||
RAW_MAGIC,
|
||||
@@ -256,3 +257,85 @@ def test_capture_reader_rejects_invalid_or_unbounded_frames(
|
||||
|
||||
with pytest.raises(CaptureFormatError, match=message):
|
||||
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:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
_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
|
||||
|
||||
|
||||
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:
|
||||
capture = tmp_path / "payloads.tsv"
|
||||
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")
|
||||
|
||||
Reference in New Issue
Block a user