From db8d365ad906a848691201b804dfcd381f5aa35a Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 17:36:33 +0000 Subject: [PATCH] Cover tape-NAS data-plane hydration scenarios (Phase 6, review #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review asked to test real data-plane behaviors, not only REST. The data plane is an in-process virtual FS + hydration cache (no libfuse mount yet), so the meaningful behaviors are tested at that level: - offline read errors clearly until hydrated, then serves real bytes to dest - eviction forces re-hydration (no stale serve) - concurrent opens of an offline file all fail cleanly (no race/crash) - missing file -> FileNotFoundError; write/delete -> PermissionError (read-only) Assessed Phase 6 scope: NasFileState/VirtualFileStatus already model online/offline/hydrating/exporting/missing/corrupt (review overstated this gap too); the genuinely-missing bits (foreign media, vault, fragmentation, small-file aggregation, sequestered) are deferred rather than added as unused enum values. A containerized SMB/NFS client harness is a follow-up once the FS is wired to libfuse — there is no real mount to serve yet. 12 fuse tests green (5 new + 7 existing). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- tests/unit/test_fuse_data_plane_scenarios.py | 87 ++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/unit/test_fuse_data_plane_scenarios.py diff --git a/tests/unit/test_fuse_data_plane_scenarios.py b/tests/unit/test_fuse_data_plane_scenarios.py new file mode 100644 index 0000000..fd7b33e --- /dev/null +++ b/tests/unit/test_fuse_data_plane_scenarios.py @@ -0,0 +1,87 @@ +"""Tape-NAS data-plane behavioral scenarios (roadmap Phase 6, review #9). + +Exercises the hydration lifecycle of the in-process virtual filesystem + hydration +cache — the logic a real SMB/NFS/FUSE mount would sit on top of: offline-tape +errors, hydrate-then-read, eviction forcing re-hydration, concurrent opens of an +offline file, and read-only enforcement. (A real containerized SMB/NFS client +harness is a follow-up once the mount is wired to libfuse; the FS is currently an +in-process model, so the meaningful behaviors are tested here directly.) +""" + +from __future__ import annotations + +import concurrent.futures +import hashlib +from pathlib import Path + +import pytest + +from openblade.catalog.db import get_session, init_db +from openblade.catalog.repository import CatalogRepository +from openblade.domain.errors import CartridgeOfflineError +from openblade.fuse.filesystem import CatalogFilesystem + +DATA = b"tape-resident payload" +CHECKSUM = hashlib.sha256(DATA).hexdigest() + + +@pytest.fixture() +def fs(tmp_path: Path) -> CatalogFilesystem: + init_db("sqlite:///:memory:") + repo = CatalogRepository(get_session()) + group = repo.create_volume_group("vg-1") + repo.create_file_record("/data/file.bin", len(DATA), CHECKSUM, group.id) + return CatalogFilesystem(repo, cache_dir=str(tmp_path / "cache")) + + +def test_offline_read_errors_until_hydrated_then_succeeds(fs: CatalogFilesystem, tmp_path: Path) -> None: + dest = tmp_path / "out.bin" + # Offline (not cached): a read must fail clearly, not hang or fabricate. + with pytest.raises(CartridgeOfflineError): + fs.read("/data/file.bin", str(dest)) + assert not fs.is_hydrated("/data/file.bin") + + # Hydrate (as a hydration job would), then the read serves real bytes to dest. + fs.cache.store(CHECKSUM, DATA) + assert fs.is_hydrated("/data/file.bin") + assert fs.read("/data/file.bin", str(dest)) == DATA + assert dest.read_bytes() == DATA + + +def test_eviction_forces_rehydration(fs: CatalogFilesystem, tmp_path: Path) -> None: + dest = tmp_path / "out.bin" + fs.cache.store(CHECKSUM, DATA) + assert fs.read("/data/file.bin", str(dest)) == DATA + + fs.cache.evict(CHECKSUM) # cache pressure evicts the file + + assert not fs.is_hydrated("/data/file.bin") + with pytest.raises(CartridgeOfflineError): # must re-hydrate, not serve stale + fs.read("/data/file.bin", str(dest)) + + +def test_concurrent_reads_of_offline_file_error_consistently(fs: CatalogFilesystem, tmp_path: Path) -> None: + def _attempt(index: int) -> str: + try: + fs.read("/data/file.bin", str(tmp_path / f"out-{index}.bin")) + return "ok" + except CartridgeOfflineError: + return "offline" + + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(_attempt, range(8))) + # Every concurrent open of the offline file fails cleanly — no crash, no race + # into a partial/served read. + assert results == ["offline"] * 8 + + +def test_read_missing_file_raises_not_found(fs: CatalogFilesystem, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + fs.read("/data/does-not-exist.bin", str(tmp_path / "out.bin")) + + +def test_virtual_filesystem_is_read_only(fs: CatalogFilesystem) -> None: + with pytest.raises(PermissionError): + fs.write("/data/file.bin", b"x") + with pytest.raises(PermissionError): + fs.delete("/data/file.bin")