Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevent Windows cache identity probes from creating locked temporary files inside scanned directories.
- Preserve locked Windows cache probes reached through directory aliases while clearing stale scan results.
- Place cross-volume Windows cache identity probes near the volume root instead of the nearest ancestor of the scanned path, so a probe can no longer appear inside a directory tree that a concurrent scan is walking.
- Keep published scan-result cache entries readable when concurrent or interrupted hits update access metadata.

## [0.2.52](https://github.com/promptfoo/modelaudit/compare/v0.2.51...v0.2.52) (2026-07-22)

Expand Down
40 changes: 28 additions & 12 deletions modelaudit/cache/scan_results_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,11 +589,7 @@ def _get_cached_result_with_identity(
self._record_cache_miss("invalid")
return None, file_identity

cache_entry["cache_metadata"]["access_count"] += 1
cache_entry["cache_metadata"]["last_access"] = time.time()

with open(cache_file_path, "w", encoding="utf-8") as f:
json.dump(cache_entry, f, indent=2)
self._update_cached_entry_access(cache_file_path, cache_entry)

if not self._file_identity_matches(file_path, file_identity):
self._record_cache_miss("changed")
Expand Down Expand Up @@ -718,13 +714,7 @@ def _get_cached_result_by_key(
self._record_cache_miss("invalid")
return None

# Update access statistics
cache_entry["cache_metadata"]["access_count"] += 1
cache_entry["cache_metadata"]["last_access"] = time.time()

# Write back updated entry (async write would be better but adds complexity)
with open(cache_file_path, "w", encoding="utf-8") as f:
json.dump(cache_entry, f, indent=2)
self._update_cached_entry_access(cache_file_path, cache_entry)

if (
file_path is not None
Expand All @@ -743,6 +733,32 @@ def _get_cached_result_by_key(
self._record_cache_miss("error")
return None

@staticmethod
def _update_cached_entry_access(cache_file_path: Path, cache_entry: dict[str, Any]) -> None:
cache_entry["cache_metadata"]["access_count"] += 1
cache_entry["cache_metadata"]["last_access"] = time.time()
temporary_cache_path: Path | None = None

try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=cache_file_path.parent,
prefix=f".{cache_file_path.name}.",
suffix=".tmp",
delete=False,
) as cache_file:
temporary_cache_path = Path(cache_file.name)
json.dump(cache_entry, cache_file, indent=2)
os.replace(temporary_cache_path, cache_file_path)
temporary_cache_path = None
except OSError as error:
logger.debug("Failed to update cache access metadata for %s: %s", cache_file_path.name, error)
finally:
if temporary_cache_path is not None:
with suppress(OSError):
temporary_cache_path.unlink(missing_ok=True)

@staticmethod
def _result_from_cache_entry(
cache_entry: dict[str, Any],
Expand Down
79 changes: 79 additions & 0 deletions tests/cache/test_cache_correctness.py
Original file line number Diff line number Diff line change
Expand Up @@ -5392,6 +5392,85 @@ def checked_replace(source: str | os.PathLike[str], destination: str | os.PathLi
assert replace_calls[0][1].is_file()


@pytest.mark.parametrize("lookup_kind", ["path", "key"])
def test_cache_hit_keeps_published_entry_readable_during_access_update(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
lookup_kind: str,
) -> None:
file_path = _make_cacheable_file(tmp_path, name="atomic-hit.cache")
cache = ScanResultsCache(str(tmp_path / "scan-cache"))
version_context = build_cache_version_context({"timeout": 30})
expected = {"checks": [], "issues": [], "metadata": {}, "scanner": "test", "success": True}

assert cache.store_result(
str(file_path), expected, version_context=version_context, **_identity_kwargs(cache, str(file_path))
)
cache_key = cache.generate_cache_key(str(file_path), version_context=version_context)
assert cache_key is not None
cache_file_path = cache._get_cache_file_path(cache_key)
observed_entries: list[dict[str, Any]] = []
original_dump = json.dump

def observe_published_entry(value: Any, destination: Any, *args: Any, **kwargs: Any) -> None:
if isinstance(value, dict) and value.get("cache_key") == cache_key:
observed_entries.append(json.loads(cache_file_path.read_text(encoding="utf-8")))
original_dump(value, destination, *args, **kwargs)

monkeypatch.setattr(scan_results_cache_module.json, "dump", observe_published_entry)

if lookup_kind == "path":
result = cache.get_cached_result(str(file_path), version_context=version_context)
else:
result = cache.get_cached_result_by_key(cache_key, file_path=str(file_path), version_context=version_context)

assert result == expected
assert len(observed_entries) == 1
assert observed_entries[0]["scan_result"] == expected
assert json.loads(cache_file_path.read_text(encoding="utf-8"))["cache_metadata"]["access_count"] == 2


@pytest.mark.parametrize("lookup_kind", ["path", "key"])
def test_cache_hit_preserves_published_entry_when_access_update_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
lookup_kind: str,
) -> None:
file_path = _make_cacheable_file(tmp_path, name="failed-hit-update.cache")
cache = ScanResultsCache(str(tmp_path / "scan-cache"))
version_context = build_cache_version_context({"timeout": 30})
expected = {"checks": [], "issues": [], "metadata": {}, "scanner": "test", "success": True}

assert cache.store_result(
str(file_path), expected, version_context=version_context, **_identity_kwargs(cache, str(file_path))
)
cache_key = cache.generate_cache_key(str(file_path), version_context=version_context)
assert cache_key is not None
cache_file_path = cache._get_cache_file_path(cache_key)
original_dump = json.dump

def interrupt_entry_update(value: Any, destination: Any, *args: Any, **kwargs: Any) -> None:
if isinstance(value, dict) and value.get("cache_key") == cache_key:
raise OSError("simulated interrupted cache access update")
original_dump(value, destination, *args, **kwargs)

with monkeypatch.context() as patch:
patch.setattr(scan_results_cache_module.json, "dump", interrupt_entry_update)
if lookup_kind == "path":
result = cache.get_cached_result(str(file_path), version_context=version_context)
else:
result = cache.get_cached_result_by_key(
cache_key,
file_path=str(file_path),
version_context=version_context,
)

assert result == expected
assert json.loads(cache_file_path.read_text(encoding="utf-8"))["scan_result"] == expected
assert not list(cache_file_path.parent.glob(f".{cache_file_path.name}.*.tmp"))
assert cache.get_cached_result(str(file_path), version_context=version_context) == expected


def test_store_result_discards_private_entry_when_final_identity_check_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
Expand Down
Loading