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