Skip to content

Commit 9b35d9f

Browse files
authored
Merge pull request #661 from jphein/perf/graph-cache
Re-reviewed: both requested changes addressed (threading.Lock on cache globals, col-ignoring documented). Merging.
2 parents 23ee2a0 + 8adf35a commit 9b35d9f

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

mempalace/palace_graph.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,32 @@
1818
import hashlib
1919
import json
2020
import os
21+
import threading
22+
import time
2123
from collections import Counter, defaultdict
2224
from datetime import datetime, timezone
2325

2426
from .config import MempalaceConfig
2527
from .palace import get_collection as _get_palace_collection
2628
from .palace import mine_lock
2729

30+
# Module-level graph cache with TTL and write-invalidation.
31+
# Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes.
32+
_graph_cache_lock = threading.Lock()
33+
_graph_cache_nodes = None
34+
_graph_cache_edges = None
35+
_graph_cache_time = 0.0
36+
_GRAPH_CACHE_TTL = 60.0 # seconds — graph changes less often than metadata
37+
38+
39+
def invalidate_graph_cache():
40+
"""Clear the graph cache. Called from mcp_server.py on writes."""
41+
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
42+
with _graph_cache_lock:
43+
_graph_cache_nodes = None
44+
_graph_cache_edges = None
45+
_graph_cache_time = 0.0
46+
2847

2948
def _get_collection(config=None):
3049
config = config or MempalaceConfig()
@@ -42,10 +61,25 @@ def build_graph(col=None, config=None):
4261
"""
4362
Build the palace graph from ChromaDB metadata.
4463
64+
Returns cached result if fresh (within TTL). Cache is invalidated
65+
on writes via invalidate_graph_cache(). Thread-safe via _graph_cache_lock.
66+
67+
Note: warm cache ignores ``col`` and ``config`` arguments — this is
68+
intentional for the MCP server's single-palace use case. Callers
69+
switching collections should call ``invalidate_graph_cache()`` first.
70+
4571
Returns:
4672
nodes: dict of {room: {wings: set, halls: set, count: int}}
4773
edges: list of {room, wing_a, wing_b, hall} — one per tunnel crossing
4874
"""
75+
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
76+
now = time.time()
77+
# NOTE: warm cache ignores col/config args — intentional for the MCP server's
78+
# single-palace use case. Callers switching collections must invalidate first.
79+
with _graph_cache_lock:
80+
if _graph_cache_nodes is not None and (now - _graph_cache_time) < _GRAPH_CACHE_TTL:
81+
return _graph_cache_nodes, _graph_cache_edges
82+
4983
if col is None:
5084
col = _get_collection(config)
5185
if not col:
@@ -101,6 +135,14 @@ def build_graph(col=None, config=None):
101135
"dates": sorted(data["dates"])[-5:] if data["dates"] else [],
102136
}
103137

138+
# Only cache non-empty graphs so new data is picked up immediately
139+
# when the palace is first populated.
140+
if nodes:
141+
with _graph_cache_lock:
142+
_graph_cache_nodes = nodes
143+
_graph_cache_edges = edges
144+
_graph_cache_time = time.time()
145+
104146
return nodes, edges
105147

106148

tests/test_palace_graph.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def fake_get(limit=1000, offset=0, include=None):
3030
build_graph,
3131
find_tunnels,
3232
graph_stats,
33+
invalidate_graph_cache,
3334
traverse,
3435
)
3536

@@ -38,6 +39,9 @@ def fake_get(limit=1000, offset=0, include=None):
3839

3940

4041
class TestBuildGraph:
42+
def setup_method(self):
43+
invalidate_graph_cache()
44+
4145
def test_empty_collection(self):
4246
col = _make_fake_collection([])
4347
nodes, edges = build_graph(col=col)
@@ -114,11 +118,43 @@ def test_dates_capped_at_five(self):
114118
nodes, _ = build_graph(col=col)
115119
assert len(nodes["busy"]["dates"]) <= 5
116120

121+
def test_cache_returns_same_result(self):
122+
"""Second call within TTL returns cached nodes without re-scanning.
123+
124+
The cache intentionally ignores col/config args when warm — this is
125+
correct for the MCP server's single-palace use case. Callers that
126+
switch collections must call invalidate_graph_cache() first.
127+
"""
128+
col = _make_fake_collection(
129+
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
130+
)
131+
nodes1, edges1 = build_graph(col=col)
132+
# Second call with a *different* collection — should still return cached result
133+
col2 = _make_fake_collection([])
134+
nodes2, edges2 = build_graph(col=col2)
135+
assert nodes1 == nodes2
136+
assert edges1 == edges2
137+
138+
def test_invalidate_clears_cache(self):
139+
"""invalidate_graph_cache() forces a fresh scan on next call."""
140+
col = _make_fake_collection(
141+
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
142+
)
143+
build_graph(col=col)
144+
invalidate_graph_cache()
145+
col_empty = _make_fake_collection([])
146+
nodes, edges = build_graph(col=col_empty)
147+
assert nodes == {}
148+
assert edges == []
149+
117150

118151
# --- traverse ---
119152

120153

121154
class TestTraverse:
155+
def setup_method(self):
156+
invalidate_graph_cache()
157+
122158
def _build_col(self):
123159
return _make_fake_collection(
124160
[
@@ -156,6 +192,9 @@ def test_traverse_max_hops(self):
156192

157193

158194
class TestFindTunnels:
195+
def setup_method(self):
196+
invalidate_graph_cache()
197+
159198
def _build_tunnel_col(self):
160199
return _make_fake_collection(
161200
[
@@ -192,6 +231,9 @@ def test_find_tunnels_both_wings(self):
192231

193232

194233
class TestGraphStats:
234+
def setup_method(self):
235+
invalidate_graph_cache()
236+
195237
def test_empty_graph(self):
196238
col = _make_fake_collection([])
197239
stats = graph_stats(col=col)

0 commit comments

Comments
 (0)