Coverage for core / platform / cache.py: 100.0%
142 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Cache Service — Unified in-memory + optional disk cache for HART OS.
4Provides a thread-safe, namespace-aware cache with TTL expiry and LRU
5eviction. Every subsystem (TTS voices, model configs, resonance profiles,
6compute policies) can share one cache instance instead of rolling its own
7dict-with-TTL.
9Design decisions:
10- OrderedDict for O(1) LRU eviction (move_to_end on access)
11- Namespace keys: 'tts:voice:alba' — clear('tts') wipes all tts:* keys
12- Thread-safe via threading.Lock (same pattern as EventBus / PlatformConfig)
13- Hit/miss counters for observability
14- Optional diskcache persistence (try/except ImportError — it's an optional dep)
15- Sentinel _MISSING distinguishes None values from cache misses
16- Lifecycle protocol (start/stop/health) for ServiceRegistry integration
18Generalizes TTL cache patterns from:
19- compute_config.py (30s TTL dict)
20- diskcache usage in aider_core/
21- model_registry.py (in-memory model lookups)
23Usage:
24 from core.platform.cache import get_cache
26 cache = get_cache()
27 cache.set('tts:voice:alba', voice_data, ttl=600)
28 voice = cache.get('tts:voice:alba')
29 cache.clear('tts') # wipe all tts:* keys
30 cache.set_persistent('model:config:phi', config) # memory + disk
31"""
33import collections
34import logging
35import threading
36import time
37from typing import Any, Dict, Optional
39logger = logging.getLogger('hevolve.platform')
41# ═══════════════════════════════════════════════════════════════
42# Constants
43# ═══════════════════════════════════════════════════════════════
45DEFAULT_MAX_SIZE = 4096
46DEFAULT_TTL = 300 # seconds
48# Sentinel for distinguishing None values from cache misses
49_MISSING = object()
52# ═══════════════════════════════════════════════════════════════
53# Cache Entry (internal)
54# ═══════════════════════════════════════════════════════════════
56class _CacheEntry:
57 """Internal record for a cached value with expiry metadata."""
59 __slots__ = ('value', 'expires_at')
61 def __init__(self, value: Any, expires_at: Optional[float]):
62 self.value = value
63 self.expires_at = expires_at
66# ═══════════════════════════════════════════════════════════════
67# Cache Service
68# ═══════════════════════════════════════════════════════════════
70class CacheService:
71 """Thread-safe in-memory LRU cache with TTL, namespaces, and optional disk.
73 Implements the Lifecycle protocol (start/stop/health) for
74 ServiceRegistry integration.
75 """
77 def __init__(self, max_size: int = DEFAULT_MAX_SIZE,
78 default_ttl: float = DEFAULT_TTL):
79 """Initialize the cache.
81 Args:
82 max_size: Maximum number of entries. When exceeded the least
83 recently used entry is evicted. 0 means unlimited.
84 default_ttl: Default time-to-live in seconds. 0 means no expiry.
85 """
86 self._max_size = max_size
87 self._default_ttl = default_ttl
88 self._data: collections.OrderedDict[str, _CacheEntry] = (
89 collections.OrderedDict()
90 )
91 self._lock = threading.Lock()
92 self._started = False
94 # Stats
95 self._hit_count = 0
96 self._miss_count = 0
98 # Optional disk backend
99 self._disk: Any = None
100 self._init_disk()
102 # ── Lifecycle Protocol ────────────────────────────────────
104 def start(self) -> None:
105 """Start the cache service."""
106 self._started = True
107 logger.debug("CacheService started (max_size=%d, ttl=%ds)",
108 self._max_size, self._default_ttl)
110 def stop(self) -> None:
111 """Stop and clear the cache."""
112 with self._lock:
113 self._data.clear()
114 self._started = False
115 if self._disk is not None:
116 try:
117 self._disk.close()
118 except Exception:
119 pass
120 self._disk = None
121 logger.debug("CacheService stopped")
123 def health(self) -> dict:
124 """Return health status for ServiceRegistry."""
125 with self._lock:
126 size = len(self._data)
127 return {
128 'status': 'ok' if self._started else 'stopped',
129 'size': size,
130 'max_size': self._max_size,
131 'hit_count': self._hit_count,
132 'miss_count': self._miss_count,
133 'hit_rate': self.hit_rate(),
134 'disk_available': self._disk is not None,
135 }
137 # ── Core API ──────────────────────────────────────────────
139 def get(self, key: str, default: Any = None) -> Any:
140 """Retrieve a value from the cache.
142 Args:
143 key: Cache key (e.g., 'tts:voice:alba').
144 default: Value to return on miss. Defaults to None.
146 Returns:
147 Cached value, or *default* if key is missing or expired.
148 """
149 with self._lock:
150 entry = self._data.get(key)
151 if entry is None:
152 self._miss_count += 1
153 return default
155 # Check TTL expiry
156 if entry.expires_at is not None and time.monotonic() > entry.expires_at:
157 # Expired — evict
158 del self._data[key]
159 self._miss_count += 1
160 return default
162 # LRU: move to end (most recently used)
163 self._data.move_to_end(key)
164 self._hit_count += 1
165 return entry.value
167 def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
168 """Store a value in the cache.
170 Args:
171 key: Cache key.
172 value: Any value (including None — distinguished via sentinel).
173 ttl: Time-to-live in seconds. None uses the default TTL.
174 0 means no expiry.
175 """
176 if ttl is None:
177 ttl = self._default_ttl
178 expires_at = (time.monotonic() + ttl) if ttl > 0 else None
180 with self._lock:
181 # If key exists, update in place (and move to end)
182 if key in self._data:
183 self._data[key] = _CacheEntry(value, expires_at)
184 self._data.move_to_end(key)
185 else:
186 # Evict LRU if at capacity
187 if self._max_size > 0 and len(self._data) >= self._max_size:
188 self._data.popitem(last=False) # pop oldest
189 self._data[key] = _CacheEntry(value, expires_at)
191 def delete(self, key: str) -> bool:
192 """Remove a key from the cache.
194 Args:
195 key: Cache key.
197 Returns:
198 True if the key existed and was removed, False otherwise.
199 """
200 with self._lock:
201 if key in self._data:
202 del self._data[key]
203 return True
204 return False
206 def has(self, key: str) -> bool:
207 """Check if a key exists and is not expired.
209 Does NOT count as a hit or miss, and does NOT affect LRU order.
210 """
211 with self._lock:
212 entry = self._data.get(key)
213 if entry is None:
214 return False
215 if entry.expires_at is not None and time.monotonic() > entry.expires_at:
216 del self._data[key]
217 return False
218 return True
220 def clear(self, namespace: Optional[str] = None) -> int:
221 """Clear cache entries.
223 Args:
224 namespace: If provided, only keys starting with 'namespace:'
225 are removed. If None, all keys are cleared.
227 Returns:
228 Number of entries removed.
229 """
230 with self._lock:
231 if namespace is None:
232 count = len(self._data)
233 self._data.clear()
234 return count
236 prefix = namespace + ':'
237 keys_to_delete = [k for k in self._data if k.startswith(prefix)]
238 for k in keys_to_delete:
239 del self._data[k]
240 return len(keys_to_delete)
242 # ── Disk Persistence ──────────────────────────────────────
244 def set_persistent(self, key: str, value: Any,
245 ttl: Optional[float] = None) -> None:
246 """Store a value in both memory and disk cache.
248 Falls back to memory-only if diskcache is unavailable.
250 Args:
251 key: Cache key.
252 value: Any picklable value.
253 ttl: Time-to-live in seconds. None uses default TTL.
254 0 means no expiry.
255 """
256 self.set(key, value, ttl=ttl)
257 if self._disk is not None:
258 try:
259 expire = ttl if ttl is not None else self._default_ttl
260 # diskcache.set() expire=None means no expiry;
261 # expire=0 would expire immediately, so map our 0 to None
262 disk_expire = expire if expire > 0 else None
263 self._disk.set(key, value, expire=disk_expire)
264 except Exception as e:
265 logger.debug("Disk cache write failed for '%s': %s", key, e)
267 def get_persistent(self, key: str, default: Any = None) -> Any:
268 """Retrieve a value, falling back to disk if not in memory.
270 On a disk hit, the value is promoted back into memory.
272 Args:
273 key: Cache key.
274 default: Fallback value.
276 Returns:
277 Cached value or *default*.
278 """
279 # Try memory first
280 result = self.get(key, _MISSING)
281 if result is not _MISSING:
282 return result
284 # Try disk
285 if self._disk is not None:
286 try:
287 disk_val = self._disk.get(key, _MISSING)
288 if disk_val is not _MISSING:
289 # Promote to memory
290 self.set(key, disk_val)
291 return disk_val
292 except Exception as e:
293 logger.debug("Disk cache read failed for '%s': %s", key, e)
295 return default
297 # ── Stats ─────────────────────────────────────────────────
299 @property
300 def hit_count(self) -> int:
301 """Total cache hits since creation or last reset."""
302 return self._hit_count
304 @property
305 def miss_count(self) -> int:
306 """Total cache misses since creation or last reset."""
307 return self._miss_count
309 def hit_rate(self) -> float:
310 """Hit rate as a float between 0.0 and 1.0.
312 Returns 0.0 if no gets have been performed.
313 """
314 total = self._hit_count + self._miss_count
315 if total == 0:
316 return 0.0
317 return self._hit_count / total
319 def stats(self) -> Dict[str, Any]:
320 """Return a stats dictionary."""
321 with self._lock:
322 size = len(self._data)
323 return {
324 'size': size,
325 'max_size': self._max_size,
326 'hit_count': self._hit_count,
327 'miss_count': self._miss_count,
328 'hit_rate': self.hit_rate(),
329 'disk_available': self._disk is not None,
330 }
332 def reset_stats(self) -> None:
333 """Reset hit/miss counters to zero."""
334 self._hit_count = 0
335 self._miss_count = 0
337 # ── Internal ──────────────────────────────────────────────
339 def _init_disk(self) -> None:
340 """Try to initialize diskcache backend. No-op if unavailable."""
341 try:
342 import diskcache
343 import os
344 import tempfile
345 cache_dir = os.environ.get(
346 'HART_CACHE_DIR',
347 os.path.join(tempfile.gettempdir(), 'hartos_cache'),
348 )
349 self._disk = diskcache.Cache(cache_dir)
350 logger.debug("Disk cache initialized at %s", cache_dir)
351 except ImportError:
352 self._disk = None
353 logger.debug("diskcache not installed — disk persistence unavailable")
354 except Exception as e:
355 self._disk = None
356 logger.debug("Disk cache init failed: %s", e)
359# ═══════════════════════════════════════════════════════════════
360# Module-level convenience — safe access without circular imports
361# ═══════════════════════════════════════════════════════════════
363def get_cache() -> Optional[CacheService]:
364 """Get the platform CacheService (if bootstrapped).
366 Safe to call from anywhere — returns None if the platform
367 hasn't been bootstrapped yet.
369 Usage:
370 cache = get_cache()
371 if cache:
372 cache.set('my:key', value)
373 """
374 try:
375 from core.platform.registry import get_registry
376 registry = get_registry()
377 if not registry.has('cache'):
378 return None
379 return registry.get('cache')
380 except Exception:
381 return None