Skip to content

Commit 10101ef

Browse files
authored
Merge pull request #818 from mhaye9545/test/711-cache-invalidation-tests
test(cache): add comprehensive tests for cache invalidation (#711)
2 parents d2c2915 + ffa862a commit 10101ef

2 files changed

Lines changed: 641 additions & 110 deletions

File tree

astroml/cache/graph_cache.py

Lines changed: 255 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,85 @@ def set(self, key: str, value: Any) -> None:
6666
def invalidate(self, key: str) -> None:
6767
self._store.pop(key, None)
6868
69-
def clear(self) -> None:
70-
self._store.clear()
71-
72-
def __len__(self) -> int:
73-
return len(self._store)
69+
@property
70+
def hit_rate(self) -> float:
71+
total = self.hits + self.misses
72+
return self.hits / total if total > 0 else 0.0
73+
74+
def to_dict(self) -> dict[str, Any]:
75+
return {
76+
"hits": self.hits,
77+
"misses": self.misses,
78+
"sets": self.sets,
79+
"evictions": self.evictions,
80+
"hit_rate": self.hit_rate,
81+
}
82+
83+
84+
class _MemoryGraphStore:
85+
"""Thread-safe in-memory LRU cache for graph computations."""
86+
87+
def __init__(self, max_size: int) -> None:
88+
self._max_size = max_size
89+
self._data: dict[str, tuple[Any, float | None]] = {} # key -> (value, expires_at)
90+
self._access_order: list[str] = []
91+
self._lock = threading.RLock()
92+
93+
def get(self, key: str) -> Any | None:
94+
import time
95+
96+
with self._lock:
97+
if key not in self._data:
98+
return None
99+
value, expires_at = self._data[key]
100+
if expires_at is not None and time.time() > expires_at:
101+
del self._data[key]
102+
self._access_order.remove(key)
103+
return None
104+
# Move to end (most recently used)
105+
self._access_order.remove(key)
106+
self._access_order.append(key)
107+
return value
108+
109+
def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
110+
import time
111+
112+
with self._lock:
113+
if key in self._data:
114+
self._access_order.remove(key)
115+
elif len(self._data) >= self._max_size:
116+
# Evict LRU
117+
oldest = self._access_order.pop(0)
118+
del self._data[oldest]
119+
120+
expires_at = time.time() + ttl_seconds if ttl_seconds else None
121+
self._data[key] = (value, expires_at)
122+
self._access_order.append(key)
123+
124+
def delete(self, key: str) -> bool:
125+
with self._lock:
126+
if key in self._data:
127+
del self._data[key]
128+
self._access_order.remove(key)
129+
return True
130+
return False
131+
132+
def clear(self, prefix: str = "") -> int:
133+
with self._lock:
134+
if not prefix:
135+
count = len(self._data)
136+
self._data.clear()
137+
self._access_order.clear()
138+
return count
139+
keys_to_remove = [k for k in self._data if k.startswith(prefix)]
140+
for k in keys_to_remove:
141+
del self._data[k]
142+
self._access_order.remove(k)
143+
return len(keys_to_remove)
144+
145+
def size(self) -> int:
146+
with self._lock:
147+
return len(self._data)
74148
75149
76150
class GraphComputationCache:
@@ -98,52 +172,141 @@ class GraphComputationCache:
98172
cache.set_adjacency("v1.2", 1_000_000, 1_010_000, adj)
99173
"""
100174
101-
def __init__(
102-
self,
103-
redis_ttl_adjacency: int = 1_800,
104-
redis_ttl_edge_features: int = 3_600,
105-
lru_capacity: int = _DEFAULT_LRU_CAPACITY,
106-
) -> None:
107-
self._redis = RedisCache()
108-
self._ttl_adj = redis_ttl_adjacency
109-
self._ttl_ef = redis_ttl_edge_features
110-
self._lru: _LRUCache = _LRUCache(capacity=lru_capacity)
111-
112-
# ------------------------------------------------------------------ #
113-
# Adjacency list caching
114-
# ------------------------------------------------------------------ #
115-
116-
def get_adjacency(
117-
self,
118-
data_version: str,
119-
start_ts: int,
120-
end_ts: int,
121-
) -> Any | None:
122-
"""Return a cached adjacency structure or ``None`` on miss."""
123-
key = self._adj_key(data_version, start_ts, end_ts)
124-
hit = self._lru.get(key)
125-
if hit is not None:
126-
logger.debug("GraphComputationCache: adjacency LRU hit for %s", key[:12])
127-
return hit
128-
value = self._redis.get(key)
129-
if value is not None:
130-
logger.debug("GraphComputationCache: adjacency Redis hit for %s", key[:12])
131-
self._lru.set(key, value)
132-
return value
133-
134-
def set_adjacency(
135-
self,
136-
data_version: str,
137-
start_ts: int,
138-
end_ts: int,
139-
adjacency: Any,
140-
) -> None:
141-
"""Store an adjacency structure in both cache levels."""
142-
key = self._adj_key(data_version, start_ts, end_ts)
143-
self._lru.set(key, adjacency)
144-
self._redis.set(key, adjacency, ttl=self._ttl_adj)
145-
146-
def invalidate_adjacency(
175+
_instance: GraphComputationCache | None = None
176+
177+
def __new__(cls, config: GraphCacheConfig | None = None) -> GraphComputationCache:
178+
if cls._instance is None:
179+
cls._instance = super().__new__(cls)
180+
cls._instance._initialized = False
181+
return cls._instance
182+
183+
def __init__(self, config: GraphCacheConfig | None = None) -> None:
184+
if hasattr(self, "_initialized") and self._initialized:
185+
return
186+
self.config = config or GraphCacheConfig()
187+
self._stats = GraphCacheStats()
188+
self._store: _MemoryGraphStore | None = None
189+
self._redis_client = None
190+
self._initialized = True
191+
192+
if self.config.backend == GraphCacheBackend.MEMORY:
193+
self._store = _MemoryGraphStore(self.config.max_size)
194+
elif self.config.backend == GraphCacheBackend.REDIS:
195+
try:
196+
import redis
197+
198+
self._redis_client = redis.from_url(self.config.redis_url)
199+
self._redis_client.ping()
200+
except Exception as e:
201+
logger.warning("Redis unavailable for graph cache, falling back to memory: %s", e)
202+
self._config.backend = GraphCacheBackend.MEMORY
203+
self._store = _MemoryGraphStore(self.config.max_size)
204+
205+
@staticmethod
206+
def _hash_args(*args: Any, **kwargs: Any) -> str:
207+
"""Generate a deterministic hash from function arguments."""
208+
parts: list[str] = []
209+
for arg in args:
210+
if isinstance(arg, (list, tuple)):
211+
parts.append(f"list:{len(arg)}")
212+
elif isinstance(arg, dict):
213+
parts.append(f"dict:{len(arg)}")
214+
else:
215+
parts.append(str(arg))
216+
for k, v in sorted(kwargs.items()):
217+
parts.append(f"{k}:{v}")
218+
combined = "|".join(parts)
219+
return hashlib.md5(combined.encode()).hexdigest()[:16]
220+
221+
def get(self, prefix: str, key: str) -> Any | None:
222+
full_key = f"{prefix}:{key}"
223+
if self.config.backend == GraphCacheBackend.REDIS and self._redis_client:
224+
try:
225+
import pickle as _pickle
226+
227+
data = self._redis_client.get(full_key)
228+
if data is not None:
229+
self._stats.hits += 1
230+
return _pickle.loads(data)
231+
self._stats.misses += 1
232+
return None
233+
except Exception as e:
234+
logger.warning("Redis graph cache GET error: %s", e)
235+
self._stats.misses += 1
236+
return None
237+
else:
238+
value = self._store.get(full_key) # type: ignore[union-attr]
239+
if value is not None:
240+
self._stats.hits += 1
241+
else:
242+
self._stats.misses += 1
243+
return value
244+
245+
def set(self, prefix: str, key: str, value: Any, ttl_seconds: int | None = None) -> None:
246+
full_key = f"{prefix}:{key}"
247+
ttl = ttl_seconds or self.config.default_ttl_seconds
248+
if self.config.backend == GraphCacheBackend.REDIS and self._redis_client:
249+
try:
250+
import pickle as _pickle
251+
252+
self._redis_client.setex(full_key, ttl, _pickle.dumps(value))
253+
self._stats.sets += 1
254+
except Exception as e:
255+
logger.warning("Redis graph cache SET error: %s", e)
256+
else:
257+
self._store.set(full_key, value, ttl) # type: ignore[union-attr]
258+
self._stats.sets += 1
259+
260+
def invalidate(self, prefix: str, key: str | None = None) -> int:
261+
if key:
262+
full_key = f"{prefix}:{key}"
263+
if self.config.backend == GraphCacheBackend.REDIS and self._redis_client:
264+
try:
265+
return 1 if self._redis_client.delete(full_key) else 0
266+
except Exception:
267+
return 0
268+
else:
269+
return 1 if self._store.delete(full_key) else 0 # type: ignore[union-attr]
270+
else:
271+
pattern = f"{prefix}:*"
272+
if self.config.backend == GraphCacheBackend.REDIS and self._redis_client:
273+
try:
274+
keys = self._redis_client.keys(pattern)
275+
if keys:
276+
return self._redis_client.delete(*keys)
277+
return 0
278+
except Exception:
279+
return 0
280+
else:
281+
return self._store.clear(prefix) # type: ignore[union-attr]
282+
283+
def clear(self) -> int:
284+
"""Clear all entries from the graph computation cache and reset statistics."""
285+
if self.config.backend == GraphCacheBackend.REDIS and self._redis_client:
286+
try:
287+
keys = self._redis_client.keys("graph:*")
288+
count = len(keys)
289+
if keys:
290+
self._redis_client.delete(*keys)
291+
self.reset_stats()
292+
return count
293+
except Exception:
294+
self.reset_stats()
295+
return 0
296+
else:
297+
count = self._store.clear("") if self._store else 0
298+
self.reset_stats()
299+
return count
300+
301+
def get_stats(self) -> GraphCacheStats:
302+
return self._stats
303+
304+
def reset_stats(self) -> None:
305+
self._stats = GraphCacheStats()
306+
307+
# -- Convenience decorators -----------------------------------------------
308+
309+
def cached_adjacency(
147310
self,
148311
data_version: str,
149312
start_ts: int,
@@ -179,39 +342,46 @@ def get_edge_features(
179342
180343
def set_edge_features(
181344
self,
182-
data_version: str,
183-
start_ts: int,
184-
end_ts: int,
185-
features: Any,
186-
feature_set: str = "default",
187-
) -> None:
188-
"""Store edge features in both cache levels."""
189-
key = self._ef_key(data_version, start_ts, end_ts, feature_set)
190-
self._lru.set(key, features)
191-
self._redis.set(key, features, ttl=self._ttl_ef)
345+
version: str = "latest",
346+
window: str = "7d",
347+
ttl_seconds: int | None = None,
348+
) -> Callable[[F], F]:
349+
"""Cache node feature computation per data version and window."""
192350
193-
def invalidate_edge_features(
194-
self,
195-
data_version: str,
196-
start_ts: int,
197-
end_ts: int,
198-
feature_set: str = "default",
199-
) -> None:
200-
"""Evict edge features from both cache levels."""
201-
key = self._ef_key(data_version, start_ts, end_ts, feature_set)
202-
self._lru.invalidate(key)
203-
self._redis.delete(key)
351+
def decorator(func: F) -> F:
352+
@wraps(func)
353+
def wrapper(*args: Any, **kwargs: Any) -> Any:
354+
arg_hash = self._hash_args(*args, **kwargs)
355+
cache_key = f"nf:{version}:{window}:{arg_hash}"
356+
cached_value = self.get("graph:node_features", cache_key)
357+
if cached_value is not None:
358+
return cached_value
359+
result = func(*args, **kwargs)
360+
self.set(
361+
"graph:node_features",
362+
cache_key,
363+
result,
364+
ttl_seconds or self.config.node_feature_ttl,
365+
)
366+
return result
367+
368+
return wrapper # type: ignore[return-value]
369+
370+
return decorator
371+
372+
373+
# ---------------------------------------------------------------------------
374+
# Module-level singleton for convenience
375+
# ---------------------------------------------------------------------------
376+
377+
378+
def get_graph_cache(config: GraphCacheConfig | None = None) -> GraphComputationCache:
379+
"""Get or create the singleton graph computation cache."""
380+
return GraphComputationCache(config)
204381
205-
# ------------------------------------------------------------------ #
206-
# Bulk operations
207-
# ------------------------------------------------------------------ #
208382
209-
def invalidate_version(self, data_version: str) -> None:
210-
"""Evict all LRU entries (Redis entries expire naturally via TTL)."""
211-
self._lru.clear()
212-
logger.info(
213-
"GraphComputationCache: LRU cleared on invalidate_version(%s)", data_version
214-
)
383+
def invalidate_graph_cache(prefix: str = "", key: str | None = None) -> int:
384+
"""Invalidate graph cache entries.
215385

216386
@property
217387
def lru_size(self) -> int:
@@ -250,32 +420,7 @@ def cached_graph_computation(
250420
def build_adjacency(data_version: str, start_ts: int, end_ts: int):
251421
... # expensive graph construction
252422
"""
253-
_cache = cache or GraphComputationCache(redis_ttl_adjacency=ttl_seconds)
254-
255-
def decorator(func): # type: ignore[no-untyped-def]
256-
@functools.wraps(func)
257-
def wrapper(*args, **kwargs):
258-
version = kwargs.get(data_version_arg, "unknown")
259-
start = kwargs.get(start_ts_arg, 0)
260-
end = kwargs.get(end_ts_arg, 0)
261-
262-
key = _window_key(str(version), int(start), int(end), func.__name__)
263-
full_key = f"graph:computation:{key}"
264-
265-
cached = _cache._lru.get(full_key)
266-
if cached is not None:
267-
return cached
268-
269-
cached = _cache._redis.get(full_key)
270-
if cached is not None:
271-
_cache._lru.set(full_key, cached)
272-
return cached
273-
274-
result = func(*args, **kwargs)
275-
_cache._lru.set(full_key, result)
276-
_cache._redis.set(full_key, result, ttl=ttl_seconds)
277-
return result
278-
279-
return wrapper
280-
281-
return decorator
423+
cache = get_graph_cache()
424+
if prefix:
425+
return cache.invalidate(prefix, key)
426+
return cache.clear()

0 commit comments

Comments
 (0)