"""Atomicity coverage for the NanoVectorDB save path. ``NanoVectorDB.save()`` is third-party and writes via plain ``open(self.storage_file, "w") + json.dump``. ``NanoVectorDBStorage`` wraps that call by swapping ``storage_file`` to a per-writer tmp under ``atomic_write``. The contract we have to preserve is: 1. On success: the destination file is updated, no tmp residue remains. 2. On failure: the destination is unchanged, ``client.storage_file`` is restored to the real path (so subsequent reads don't keep pointing at the tmp). """ import json import os from unittest.mock import patch import pytest nano_vectordb = pytest.importorskip("nano_vectordb") # noqa: F841 from lightrag.file_atomic import atomic_write # noqa: E402 from nano_vectordb import NanoVectorDB # noqa: E402 def _make_client(tmp_path) -> tuple[NanoVectorDB, str]: target = str(tmp_path / "vdb_test.json") client = NanoVectorDB(4, storage_file=target) return client, target def _save_atomic(client: NanoVectorDB, target: str) -> None: """Mirrors the production callback's save lambda — kept inline so the test exercises the exact swap-and-restore pattern.""" def _do(tmp: str) -> None: original = client.storage_file client.storage_file = tmp try: client.save() finally: client.storage_file = original atomic_write(target, _do) @pytest.mark.offline def test_nano_save_atomic_publishes_file_and_restores_storage_file(tmp_path): client, target = _make_client(tmp_path) _save_atomic(client, target) assert os.path.exists(target) assert client.storage_file == target # Sanity check the payload is valid JSON written by NanoVectorDB. json.load(open(target)) leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p] assert leftovers == [], f"Unexpected tmp residue: {leftovers}" @pytest.mark.offline def test_nano_save_atomic_replace_crash_preserves_prior(tmp_path): client, target = _make_client(tmp_path) _save_atomic(client, target) original_payload = open(target).read() with patch( "lightrag.file_atomic.os.replace", side_effect=OSError("simulated crash"), ): with pytest.raises(OSError, match="simulated crash"): _save_atomic(client, target) # Destination unchanged. assert open(target).read() == original_payload # storage_file must have been restored even though the outer atomic_write # raised — otherwise NanoVectorDB would silently start writing to a path # that no longer exists. assert client.storage_file == target leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p] assert leftovers == [], f"Python-exception path must clean tmp, got {leftovers}" @pytest.mark.offline def test_nano_save_failure_inside_save_restores_storage_file(tmp_path): """If ``NanoVectorDB.save()`` itself raises (e.g. encoding failure), the inner ``finally`` must restore ``storage_file`` before the exception bubbles up through ``atomic_write``.""" client, target = _make_client(tmp_path) # Patch the third-party save to blow up. with patch.object(NanoVectorDB, "save", side_effect=RuntimeError("boom")): with pytest.raises(RuntimeError, match="boom"): _save_atomic(client, target) assert ( client.storage_file == target ), "Inner save failure must still restore storage_file to the real path" leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p] assert leftovers == [], f"save failure must clean tmp, got {leftovers}"