perf: graph cache with write-invalidation in build_graph()

build_graph() scans every drawer's metadata in 1000-item batches on
every call — O(n) per graph build with no caching. At 50K+ drawers
this costs several seconds per MCP tool call (traverse, find_tunnels,
graph_stats all call build_graph on every invocation).

Add a module-level cache (nodes + edges + timestamp) with a 60-second
TTL. Cache is invalidated via invalidate_graph_cache(), exported for
write operations to call. Tests updated with setup_method cache resets
and two new tests verifying cache hit and invalidation behaviour.
This commit is contained in:
jp
2026-04-11 19:44:49 -07:00
parent d4c942417a
commit 84e2aa16e4
2 changed files with 64 additions and 0 deletions
+37
View File
@@ -30,6 +30,7 @@ with patch.dict("sys.modules", {"chromadb": MagicMock()}):
build_graph,
find_tunnels,
graph_stats,
invalidate_graph_cache,
traverse,
)
@@ -38,6 +39,9 @@ with patch.dict("sys.modules", {"chromadb": MagicMock()}):
class TestBuildGraph:
def setup_method(self):
invalidate_graph_cache()
def test_empty_collection(self):
col = _make_fake_collection([])
nodes, edges = build_graph(col=col)
@@ -114,11 +118,38 @@ class TestBuildGraph:
nodes, _ = build_graph(col=col)
assert len(nodes["busy"]["dates"]) <= 5
def test_cache_returns_same_result(self):
"""Second call within TTL returns cached nodes without re-scanning."""
col = _make_fake_collection(
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
)
nodes1, edges1 = build_graph(col=col)
# Second call with a *different* collection — should still return cached result
col2 = _make_fake_collection([])
nodes2, edges2 = build_graph(col=col2)
assert nodes1 == nodes2
assert edges1 == edges2
def test_invalidate_clears_cache(self):
"""invalidate_graph_cache() forces a fresh scan on next call."""
col = _make_fake_collection(
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
)
build_graph(col=col)
invalidate_graph_cache()
col_empty = _make_fake_collection([])
nodes, edges = build_graph(col=col_empty)
assert nodes == {}
assert edges == []
# --- traverse ---
class TestTraverse:
def setup_method(self):
invalidate_graph_cache()
def _build_col(self):
return _make_fake_collection(
[
@@ -156,6 +187,9 @@ class TestTraverse:
class TestFindTunnels:
def setup_method(self):
invalidate_graph_cache()
def _build_tunnel_col(self):
return _make_fake_collection(
[
@@ -192,6 +226,9 @@ class TestFindTunnels:
class TestGraphStats:
def setup_method(self):
invalidate_graph_cache()
def test_empty_graph(self):
col = _make_fake_collection([])
stats = graph_stats(col=col)