210 lines
7.1 KiB
Python
210 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from k1link.artifact_gateway import (
|
|
ArtifactCacheCapacityError,
|
|
ArtifactGateway,
|
|
ArtifactIntegrityError,
|
|
ArtifactStoreUnavailable,
|
|
CentralArtifactStore,
|
|
LocalArtifactCache,
|
|
)
|
|
|
|
|
|
def _gateway(
|
|
tmp_path: Path,
|
|
*,
|
|
max_bytes: int = 1024,
|
|
) -> tuple[ArtifactGateway, Path, Path]:
|
|
central_root = tmp_path / "central"
|
|
local_root = tmp_path / "local"
|
|
return (
|
|
ArtifactGateway(
|
|
CentralArtifactStore(central_root, create=True),
|
|
LocalArtifactCache(
|
|
local_root,
|
|
max_bytes=max_bytes,
|
|
free_space_reserve_bytes=0,
|
|
),
|
|
),
|
|
central_root,
|
|
local_root,
|
|
)
|
|
|
|
|
|
def test_gateway_publishes_deduplicated_objects_and_resolves_through_local_cache(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
gateway, central_root, _local_root = _gateway(tmp_path)
|
|
first = tmp_path / "first.rrd"
|
|
duplicate = tmp_path / "duplicate.rrd"
|
|
first.write_bytes(b"RRF2same-content")
|
|
duplicate.write_bytes(first.read_bytes())
|
|
|
|
manifest = gateway.publish(
|
|
namespace="sessions",
|
|
key="session-1",
|
|
artifact_type="recorded-session",
|
|
subject_id="session-1",
|
|
sources=(
|
|
("base-rrd", "application/vnd.rerun.rrd", first),
|
|
("overlay-rrd", "application/vnd.rerun.rrd", duplicate),
|
|
),
|
|
metadata={"display-name": "Session 1"},
|
|
)
|
|
|
|
assert len(manifest.members) == 2
|
|
assert manifest.members[0].sha256 == manifest.members[1].sha256
|
|
objects = tuple((central_root / "objects" / "sha256").glob("*/*"))
|
|
assert len(objects) == 1
|
|
|
|
cold = gateway.resolve_role("sessions", "session-1", "overlay-rrd")
|
|
assert cold.path.read_bytes() == first.read_bytes()
|
|
assert cold.cache_hit is False
|
|
assert cold.central_available is True
|
|
|
|
warm = gateway.resolve_role("sessions", "session-1", "overlay-rrd")
|
|
assert warm.path == cold.path
|
|
assert warm.cache_hit is True
|
|
|
|
|
|
def test_persistent_pin_survives_restart_and_resolves_with_central_offline(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
gateway, central_root, local_root = _gateway(tmp_path)
|
|
source = tmp_path / "overlay.rrd"
|
|
source.write_bytes(b"RRF2offline-ready")
|
|
gateway.publish(
|
|
namespace="sessions",
|
|
key="session-1",
|
|
artifact_type="recorded-session",
|
|
subject_id="session-1",
|
|
sources=(("overlay-rrd", "application/vnd.rerun.rrd", source),),
|
|
)
|
|
gateway.pin_reference("sessions", "session-1", pin_id="session-1")
|
|
offline_root = tmp_path / "central-offline"
|
|
os.replace(central_root, offline_root)
|
|
|
|
restarted = ArtifactGateway(
|
|
gateway.store,
|
|
LocalArtifactCache(
|
|
local_root,
|
|
max_bytes=1024,
|
|
free_space_reserve_bytes=0,
|
|
),
|
|
)
|
|
|
|
resolved = restarted.resolve_role("sessions", "session-1", "overlay-rrd")
|
|
assert resolved.path.read_bytes() == source.read_bytes()
|
|
assert resolved.cache_hit is True
|
|
assert resolved.central_available is False
|
|
status = restarted.cache.status()
|
|
assert status.pinned_object_count == 1
|
|
assert status.pinned_bytes == len(source.read_bytes())
|
|
|
|
|
|
def test_offline_cache_miss_is_explicit(tmp_path: Path) -> None:
|
|
gateway, central_root, local_root = _gateway(tmp_path)
|
|
source = tmp_path / "overlay.rrd"
|
|
source.write_bytes(b"RRF2not-fetched")
|
|
gateway.publish(
|
|
namespace="sessions",
|
|
key="session-1",
|
|
artifact_type="recorded-session",
|
|
subject_id="session-1",
|
|
sources=(("overlay-rrd", "application/vnd.rerun.rrd", source),),
|
|
)
|
|
os.replace(central_root, tmp_path / "central-offline")
|
|
|
|
restarted = ArtifactGateway(
|
|
gateway.store,
|
|
LocalArtifactCache(local_root, max_bytes=1024, free_space_reserve_bytes=0),
|
|
)
|
|
|
|
with pytest.raises(ArtifactStoreUnavailable, match="offline cache"):
|
|
restarted.resolve_role("sessions", "session-1", "overlay-rrd")
|
|
|
|
|
|
def test_unpinned_lru_is_evicted_but_pinned_object_is_preserved(tmp_path: Path) -> None:
|
|
gateway, _central_root, _local_root = _gateway(tmp_path, max_bytes=18)
|
|
first = tmp_path / "first.bin"
|
|
second = tmp_path / "second.bin"
|
|
third = tmp_path / "third.bin"
|
|
first.write_bytes(b"a" * 9)
|
|
second.write_bytes(b"b" * 9)
|
|
third.write_bytes(b"c" * 9)
|
|
for key, source in (("first", first), ("second", second), ("third", third)):
|
|
gateway.publish(
|
|
namespace="objects",
|
|
key=key,
|
|
artifact_type="test-object",
|
|
subject_id=key,
|
|
sources=(("payload", "application/octet-stream", source),),
|
|
)
|
|
|
|
gateway.pin_reference("objects", "first", pin_id="keep-first")
|
|
second_resolved = gateway.resolve_role("objects", "second", "payload")
|
|
assert second_resolved.path.is_file()
|
|
gateway.resolve_role("objects", "third", "payload")
|
|
|
|
first_member = gateway.store.resolve_reference("objects", "first").member("payload")
|
|
second_member = gateway.store.resolve_reference("objects", "second").member("payload")
|
|
third_member = gateway.store.resolve_reference("objects", "third").member("payload")
|
|
assert gateway.cache.get(first_member) is not None
|
|
assert gateway.cache.get(second_member) is None
|
|
assert gateway.cache.get(third_member) is not None
|
|
|
|
|
|
def test_cache_refuses_object_larger_than_its_quota(tmp_path: Path) -> None:
|
|
gateway, _central_root, _local_root = _gateway(tmp_path, max_bytes=8)
|
|
source = tmp_path / "large.bin"
|
|
source.write_bytes(b"x" * 9)
|
|
gateway.publish(
|
|
namespace="objects",
|
|
key="large",
|
|
artifact_type="test-object",
|
|
subject_id="large",
|
|
sources=(("payload", "application/octet-stream", source),),
|
|
)
|
|
|
|
with pytest.raises(ArtifactCacheCapacityError, match="exceeds"):
|
|
gateway.resolve_role("objects", "large", "payload")
|
|
|
|
|
|
def test_same_size_local_corruption_is_detected_after_stat_identity_changes(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
gateway, _central_root, _local_root = _gateway(tmp_path)
|
|
source = tmp_path / "payload.bin"
|
|
source.write_bytes(b"original")
|
|
gateway.publish(
|
|
namespace="objects",
|
|
key="payload",
|
|
artifact_type="test-object",
|
|
subject_id="payload",
|
|
sources=(("payload", "application/octet-stream", source),),
|
|
)
|
|
resolved = gateway.resolve_role("objects", "payload", "payload")
|
|
resolved.path.write_bytes(b"tampered")
|
|
os.utime(resolved.path, ns=(resolved.path.stat().st_atime_ns, 1))
|
|
|
|
member = gateway.store.resolve_reference("objects", "payload").member("payload")
|
|
assert gateway.cache.get(member) is None
|
|
repaired = gateway.resolve_role("objects", "payload", "payload")
|
|
assert repaired.path.read_bytes() == b"original"
|
|
|
|
|
|
def test_store_rejects_corrupt_existing_content_addressed_object(tmp_path: Path) -> None:
|
|
store = CentralArtifactStore(tmp_path / "central", create=True)
|
|
source = tmp_path / "source.bin"
|
|
source.write_bytes(b"original")
|
|
published = store.publish_file(source)
|
|
store.object_path(published.sha256).write_bytes(b"tampered")
|
|
|
|
with pytest.raises(ArtifactIntegrityError, match="corrupt"):
|
|
store.publish_file(source)
|