fix(cache): surface corrupt semantic cache entries instead of silently missing (#2683)

This commit is contained in:
Ousama Ben Younes
2026-08-13 13:30:24 +01:00
committed by safishamsi
parent a30b56e2df
commit 5d8d6113db
2 changed files with 59 additions and 2 deletions
+28 -2
View File
@@ -80,6 +80,12 @@ _PROMPT_FP_LEN = 12
# check_semantic_cache can report N to the user (#1939).
_legacy_semantic_hits = 0
# Count of cache entries that failed to parse as JSON this process. A corrupt
# entry is not a miss: left in place it fails on every future run, silently
# re-extracting (and, for semantic kinds, re-billing) the file forever. The
# counter lets check_semantic_cache surface one aggregate warning (#2405).
_corrupt_cache_entries = 0
# Prompt-file fingerprints already computed, keyed by (path, size, mtime_ns) —
# the same stat signature the hash index uses. check_semantic_cache resolves the
# prompt once per FILE in the corpus, so without this a 500-doc run re-reads and
@@ -896,7 +902,7 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
``merge_existing``) pass allow_legacy=False.
Returns None if no cache entry or file has changed.
"""
global _legacy_semantic_hits
global _legacy_semantic_hits, _corrupt_cache_entries
location = cache_root if cache_root is not None else root
try:
h = file_hash(path, root, cache_root=cache_root)
@@ -912,7 +918,14 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
if entry.exists():
try:
result = json.loads(entry.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError:
# Corrupt entry, not a miss: a truncated write or a bad producer
# (e.g. unescaped Windows backslashes in source_file) leaves JSON
# that fails to parse on every future run, so the file is silently
# re-extracted forever. Count it so the run can report it (#2405).
_corrupt_cache_entries += 1
return None
except OSError:
return None
# A ``partial`` entry was produced from a truncated LLM response and
# covers only part of the file's symbols. Serving it as authoritative
@@ -1154,6 +1167,7 @@ def check_semantic_cache(
cached_hyperedges: list[dict] = []
uncached: list[str] = []
legacy_before = _legacy_semantic_hits
corrupt_before = _corrupt_cache_entries
for fpath in files:
p = Path(fpath)
@@ -1180,6 +1194,18 @@ def check_semantic_cache(
stacklevel=2,
)
corrupt = _corrupt_cache_entries - corrupt_before
if corrupt:
warnings.warn(
f"{corrupt} semantic cache entr{'y' if corrupt == 1 else 'ies'} could "
"not be parsed as JSON and were treated as misses, so those files were "
"re-extracted. A corrupt entry stays on disk and fails again every run; "
"run with --force (or GRAPHIFY_FORCE=1) to rewrite them, or clear the "
"cache to stop paying for the re-extraction (#2405).",
RuntimeWarning,
stacklevel=2,
)
return cached_nodes, cached_edges, cached_hyperedges, uncached
+31
View File
@@ -1455,3 +1455,34 @@ def test_file_hash_fastpath_still_serves_a_settled_file(tmp_path, monkeypatch):
second = file_hash(f, tmp_path)
assert second == first
assert reads == [], "settled file was re-read; the stat fastpath is dead"
def test_corrupt_semantic_entry_warns_and_is_a_miss(tmp_path):
"""A corrupt (invalid-JSON) cache entry must not be silently swallowed
(#2405). Left unreported it fails to parse on every future run, re-billing
the semantic extraction forever with no diagnostic. check_semantic_cache
treats it as a miss (uncached) AND emits one aggregate warning naming the
count, mirroring the pre-fingerprint legacy-hit warning."""
from graphify.cache import (
check_semantic_cache,
save_semantic_cache,
cache_dir,
)
f = tmp_path / "doc.md"
f.write_text("# Doc\n\nBody.\n")
save_semantic_cache([{"id": "n", "source_file": "doc.md"}], [], root=tmp_path)
# Corrupt the on-disk entry (e.g. an old producer wrote unescaped
# backslashes, or a partial write left truncated JSON).
h = file_hash(f, tmp_path)
entry = cache_dir(tmp_path, "semantic") / f"{h}.json"
assert entry.exists()
entry.write_text('{"nodes": [ this is not valid json')
with pytest.warns(RuntimeWarning, match="corrupt"):
nodes, _, _, uncached = check_semantic_cache([str(f)], root=tmp_path)
# The corrupt entry is a miss, so the file is re-dispatched for extraction.
assert nodes == []
assert uncached == [str(f)]