mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
perf(watch): compute each stored source path's identity once per update
_reconcile_existing_graph asks _StoredSourcePaths.identity() for every
node, edge and hyperedge, several times per item. On graphify's own repo
that is ~150k calls for under 1k distinct files, each rebuilding Path
objects and an abspath. The answer depends only on the stored path and
roots fixed in __init__, so cache it (and in_watch_root) per instance.
Reconcile step on this repo: 10.3 s -> 2.7 s. graph.json is byte-identical
with and without the change for a full build and for an incremental
update with an edited, a deleted and an added file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 1360728f0f)
This commit is contained in:
1 parent
0083ee78e5
commit
1d38e84a91
2 files changed
+81
-1
No files matched your search
+22
-1
@@ -440,6 +440,8 @@ class _StoredSourcePaths:
|
||||
self.watch_root = watch_root
|
||||
self._normalize_source = normalize_source
|
||||
self.existing_source_root = project_root
|
||||
self._identity_cache: dict[str, str | None] = {}
|
||||
self._in_watch_root_cache: dict[str, bool] = {}
|
||||
relative_marker_prefix: str | None = None
|
||||
|
||||
root_marker = out / ".graphify_root"
|
||||
@@ -539,14 +541,33 @@ class _StoredSourcePaths:
|
||||
return Path(os.path.abspath(source_path)).as_posix()
|
||||
|
||||
def identity(self, source_file: str | None) -> str | None:
|
||||
# Every node, edge and hyperedge asks for its file's identity, several
|
||||
# times per item, but a graph has far fewer distinct source files than
|
||||
# items. The answer depends only on ``source_file`` and roots fixed in
|
||||
# __init__, so compute it once per file instead of rebuilding Path
|
||||
# objects (and an abspath) on every call.
|
||||
if not isinstance(source_file, str):
|
||||
return self._identity_uncached(source_file)
|
||||
try:
|
||||
return self._identity_cache[source_file]
|
||||
except KeyError:
|
||||
identity = self._identity_cache[source_file] = self._identity_uncached(source_file)
|
||||
return identity
|
||||
|
||||
def _identity_uncached(self, source_file: str | None) -> str | None:
|
||||
normalized = self._normalize_source(source_file)
|
||||
if normalized and not Path(normalized).is_absolute() and self.legacy_watch_relative:
|
||||
return self.absolute_identity(normalized, self.watch_root)
|
||||
return self.absolute_identity(normalized, self.existing_source_root)
|
||||
|
||||
def in_watch_root(self, source_file: str | None) -> bool:
|
||||
if isinstance(source_file, str) and source_file in self._in_watch_root_cache:
|
||||
return self._in_watch_root_cache[source_file]
|
||||
identity = self.identity(source_file)
|
||||
return bool(identity) and _is_relative_to(Path(identity), self.watch_root)
|
||||
inside = bool(identity) and _is_relative_to(Path(identity), self.watch_root)
|
||||
if isinstance(source_file, str):
|
||||
self._in_watch_root_cache[source_file] = inside
|
||||
return inside
|
||||
|
||||
def is_evicted(self, item: dict, identities: set[str]) -> bool:
|
||||
return self.identity(item.get("source_file")) in identities
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""`_StoredSourcePaths.identity` is asked for every node, edge and hyperedge's
|
||||
file, several times per item, during an update's reconcile pass. A graph has
|
||||
far fewer distinct source files than items (graphify's own repo: ~1k files,
|
||||
~18.7k nodes, ~38k edges, ~150k identity calls), and the answer depends only on
|
||||
the stored path and roots fixed in __init__. It is computed once per file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.build import _norm_source_file
|
||||
from graphify.watch import _StoredSourcePaths
|
||||
|
||||
|
||||
def _paths(tmp_path: Path, normalize=_norm_source_file) -> _StoredSourcePaths:
|
||||
out = tmp_path / "graphify-out"
|
||||
out.mkdir()
|
||||
return _StoredSourcePaths(
|
||||
{"nodes": [], "links": []},
|
||||
out=out,
|
||||
project_root=tmp_path,
|
||||
watch_root=tmp_path,
|
||||
normalize_source=normalize,
|
||||
)
|
||||
|
||||
|
||||
def test_identity_is_computed_once_per_source_file(tmp_path):
|
||||
calls: list[str | None] = []
|
||||
|
||||
def counting_normalize(p, root=None):
|
||||
calls.append(p)
|
||||
return _norm_source_file(p, root)
|
||||
|
||||
paths = _paths(tmp_path, counting_normalize)
|
||||
files = ["src/a.py", "src/b.py", "lib\\c.py"]
|
||||
for f in files:
|
||||
paths.identity(f)
|
||||
paths.in_watch_root(f)
|
||||
first_pass = len(calls)
|
||||
assert first_pass > 0
|
||||
for _ in range(50):
|
||||
for f in files:
|
||||
paths.identity(f)
|
||||
paths.in_watch_root(f)
|
||||
assert len(calls) == first_pass, f"{len(calls)} normalizations for {len(files)} files"
|
||||
|
||||
|
||||
def test_cached_answers_match_a_fresh_computation(tmp_path):
|
||||
paths = _paths(tmp_path)
|
||||
outside = (tmp_path.parent / "elsewhere" / "x.py").as_posix()
|
||||
cases = ["src/a.py", "./src/../src/a.py", "lib\\c.py", outside, "", None]
|
||||
for f in cases:
|
||||
for _ in range(2): # first call fills the cache, second reads it
|
||||
assert paths.identity(f) == paths._identity_uncached(f)
|
||||
identity = paths._identity_uncached(f)
|
||||
expected = bool(identity) and Path(identity).is_relative_to(paths.watch_root)
|
||||
assert paths.in_watch_root(f) is expected
|
||||
assert paths.in_watch_root("src/a.py") is True
|
||||
assert paths.in_watch_root(outside) is False
|
||||
Reference in new issue
Block a user