641 lines
23 KiB
Python
641 lines
23 KiB
Python
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 ReplayArtifact, ReplayCommand
|
|
from k1link.sessions.recording import (
|
|
CACHE_SCHEMA,
|
|
RECORDING_CACHE_FILENAME,
|
|
RECORDING_CACHE_SIDECAR_FILENAME,
|
|
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",
|
|
plugin_id="test.recording-plugin",
|
|
allowed_root=tmp_path,
|
|
session_root=tmp_path,
|
|
primary_artifact_id="primary",
|
|
artifacts=(
|
|
ReplayArtifact(
|
|
artifact_id="primary",
|
|
path=source,
|
|
media_type="application/x-test-recording",
|
|
file_byte_length=source.stat().st_size,
|
|
replay_byte_length=source.stat().st_size,
|
|
expected_sha256=None,
|
|
),
|
|
ReplayArtifact(
|
|
artifact_id="index",
|
|
path=metadata,
|
|
media_type="application/x-ndjson",
|
|
file_byte_length=metadata.stat().st_size,
|
|
replay_byte_length=metadata.stat().st_size,
|
|
expected_sha256=None,
|
|
),
|
|
),
|
|
timeline_origin_epoch_ns=0,
|
|
timeline_origin_monotonic_ns=0,
|
|
speed=1.0,
|
|
loop=False,
|
|
)
|
|
|
|
|
|
def _replace_primary(command: ReplayCommand, **changes: object) -> ReplayCommand:
|
|
replacement = replace(command.primary_artifact, **changes)
|
|
return replace(
|
|
command,
|
|
artifacts=tuple(
|
|
replacement if artifact.artifact_id == command.primary_artifact_id else artifact
|
|
for artifact in command.artifacts
|
|
),
|
|
)
|
|
|
|
|
|
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(RECORDING_CACHE_SIDECAR_FILENAME)
|
|
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
|
|
|
|
|
|
def test_default_recording_cache_has_no_application_byte_quota(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("MISSIONCORE_RRD_CACHE_MAX_BYTES", raising=False)
|
|
|
|
materializer = SessionRecordingMaterializer(
|
|
tmp_path / "private",
|
|
exporter=FakeExporter(),
|
|
)
|
|
|
|
assert materializer.cache_max_bytes is None
|
|
|
|
|
|
def test_delete_cached_refuses_a_live_lease_then_removes_the_exact_cache(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
command = _command(tmp_path / "source")
|
|
materializer = SessionRecordingMaterializer(
|
|
tmp_path / "private",
|
|
exporter=FakeExporter(),
|
|
)
|
|
recording, release = materializer.materialize_pinned(command)
|
|
|
|
assert materializer.delete_cached(command.session_id) is False
|
|
assert recording.path.is_file()
|
|
|
|
release()
|
|
assert materializer.delete_cached(command.session_id) is True
|
|
assert not recording.path.parent.exists()
|
|
assert command.primary_artifact.path.is_file()
|
|
|
|
|
|
def test_materializer_rejects_changed_digest_bound_secondary_artifact(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
command = _command(tmp_path / "source")
|
|
secondary = command.artifacts[1]
|
|
expected = _sha256(secondary.path)
|
|
command = replace(
|
|
command,
|
|
artifacts=(
|
|
command.artifacts[0],
|
|
replace(secondary, expected_sha256=expected),
|
|
),
|
|
)
|
|
secondary.path.write_text('{"record_type":"tampered","sequence":1}\n', encoding="utf-8")
|
|
command = replace(
|
|
command,
|
|
artifacts=(
|
|
command.artifacts[0],
|
|
replace(
|
|
command.artifacts[1],
|
|
file_byte_length=secondary.path.stat().st_size,
|
|
replay_byte_length=secondary.path.stat().st_size,
|
|
),
|
|
),
|
|
)
|
|
exporter = FakeExporter()
|
|
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
|
|
|
|
with pytest.raises(RecordingMaterializationError, match="digest.*catalog"):
|
|
materializer.materialize(command)
|
|
|
|
assert exporter.calls == 0
|
|
|
|
|
|
@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(RECORDING_CACHE_SIDECAR_FILENAME)
|
|
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(RECORDING_CACHE_SIDECAR_FILENAME)
|
|
published_sidecar = sidecar.read_bytes()
|
|
command.primary_artifact.path.write_bytes(b"new-source-that-requires-rebuild")
|
|
changed = _replace_primary(
|
|
command,
|
|
file_byte_length=command.primary_artifact.path.stat().st_size,
|
|
replay_byte_length=command.primary_artifact.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.primary_artifact.path.write_bytes(b"new-native-source")
|
|
command = _replace_primary(
|
|
command,
|
|
file_byte_length=command.primary_artifact.path.stat().st_size,
|
|
replay_byte_length=command.primary_artifact.path.stat().st_size,
|
|
)
|
|
updated = materializer.materialize(command)
|
|
assert exporter.calls == 3
|
|
assert updated.source_sha256 == _sha256(command.primary_artifact.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 / RECORDING_CACHE_FILENAME
|
|
outside_recording.write_bytes(b"do-not-touch")
|
|
(session_cache / RECORDING_CACHE_FILENAME).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.primary_artifact.path.read_bytes())
|
|
command.primary_artifact.path.unlink()
|
|
command.primary_artifact.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.primary_artifact.replay_byte_length
|
|
committed_metadata_bytes = command.artifacts[1].replay_byte_length
|
|
with command.primary_artifact.path.open("ab") as stream:
|
|
stream.write(b"uncommitted-raw-tail")
|
|
metadata = command.primary_artifact.path.with_name("mqtt.metadata.jsonl")
|
|
with metadata.open("ab") as stream:
|
|
stream.write(b'{"record_type":"message"')
|
|
observed: list[tuple[int, int, set[str]]] = []
|
|
|
|
class PrefixExporter(FakeExporter):
|
|
def __call__(
|
|
self,
|
|
source: Path,
|
|
destination: Path,
|
|
*,
|
|
artifacts: dict[str, Path],
|
|
) -> dict[str, object]:
|
|
observed.append(
|
|
(
|
|
source.stat().st_size,
|
|
artifacts["index"].stat().st_size,
|
|
set(artifacts),
|
|
)
|
|
)
|
|
assert artifacts["primary"] == source
|
|
assert all(path.parent == source.parent for path in artifacts.values())
|
|
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, {"primary", "index"})]
|
|
assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
|
|
assert command.primary_artifact.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.primary_artifact.path.read_bytes()
|
|
|
|
second_recording = materializer.materialize(second)
|
|
|
|
assert not first_recording.path.exists()
|
|
assert second_recording.path.exists()
|
|
assert first.primary_artifact.path.read_bytes() == first_source_bytes
|
|
assert second.primary_artifact.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.primary_artifact.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.primary_artifact.path.read_bytes() == source_bytes
|