@@ -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
76150class 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