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()