Files
2026-07-13 12:08:54 +08:00

100 lines
3.5 KiB
Python

"""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}"