feat(sessions): add durable observation archive and replay API

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 17:50:54 +03:00
parent aa2df560b7
commit 656f0c524d
23 changed files with 12492 additions and 5 deletions
+40
View File
@@ -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
+338
View File
@@ -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)
+83
View File
@@ -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()
+324
View File
@@ -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
+377
View File
@@ -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()
+522
View File
@@ -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
+573
View File
@@ -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"] == []
+37
View File
@@ -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")