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

1""" 

2Cache Service — Unified in-memory + optional disk cache for HART OS. 

3 

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. 

8 

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 

17 

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) 

22 

23Usage: 

24 from core.platform.cache import get_cache 

25 

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""" 

32 

33import collections 

34import logging 

35import threading 

36import time 

37from typing import Any, Dict, Optional 

38 

39logger = logging.getLogger('hevolve.platform') 

40 

41# ═══════════════════════════════════════════════════════════════ 

42# Constants 

43# ═══════════════════════════════════════════════════════════════ 

44 

45DEFAULT_MAX_SIZE = 4096 

46DEFAULT_TTL = 300 # seconds 

47 

48# Sentinel for distinguishing None values from cache misses 

49_MISSING = object() 

50 

51 

52# ═══════════════════════════════════════════════════════════════ 

53# Cache Entry (internal) 

54# ═══════════════════════════════════════════════════════════════ 

55 

56class _CacheEntry: 

57 """Internal record for a cached value with expiry metadata.""" 

58 

59 __slots__ = ('value', 'expires_at') 

60 

61 def __init__(self, value: Any, expires_at: Optional[float]): 

62 self.value = value 

63 self.expires_at = expires_at 

64 

65 

66# ═══════════════════════════════════════════════════════════════ 

67# Cache Service 

68# ═══════════════════════════════════════════════════════════════ 

69 

70class CacheService: 

71 """Thread-safe in-memory LRU cache with TTL, namespaces, and optional disk. 

72 

73 Implements the Lifecycle protocol (start/stop/health) for 

74 ServiceRegistry integration. 

75 """ 

76 

77 def __init__(self, max_size: int = DEFAULT_MAX_SIZE, 

78 default_ttl: float = DEFAULT_TTL): 

79 """Initialize the cache. 

80 

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 

93 

94 # Stats 

95 self._hit_count = 0 

96 self._miss_count = 0 

97 

98 # Optional disk backend 

99 self._disk: Any = None 

100 self._init_disk() 

101 

102 # ── Lifecycle Protocol ──────────────────────────────────── 

103 

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) 

109 

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") 

122 

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 } 

136 

137 # ── Core API ────────────────────────────────────────────── 

138 

139 def get(self, key: str, default: Any = None) -> Any: 

140 """Retrieve a value from the cache. 

141 

142 Args: 

143 key: Cache key (e.g., 'tts:voice:alba'). 

144 default: Value to return on miss. Defaults to None. 

145 

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 

154 

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 

161 

162 # LRU: move to end (most recently used) 

163 self._data.move_to_end(key) 

164 self._hit_count += 1 

165 return entry.value 

166 

167 def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None: 

168 """Store a value in the cache. 

169 

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 

179 

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) 

190 

191 def delete(self, key: str) -> bool: 

192 """Remove a key from the cache. 

193 

194 Args: 

195 key: Cache key. 

196 

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 

205 

206 def has(self, key: str) -> bool: 

207 """Check if a key exists and is not expired. 

208 

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 

219 

220 def clear(self, namespace: Optional[str] = None) -> int: 

221 """Clear cache entries. 

222 

223 Args: 

224 namespace: If provided, only keys starting with 'namespace:' 

225 are removed. If None, all keys are cleared. 

226 

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 

235 

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) 

241 

242 # ── Disk Persistence ────────────────────────────────────── 

243 

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. 

247 

248 Falls back to memory-only if diskcache is unavailable. 

249 

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) 

266 

267 def get_persistent(self, key: str, default: Any = None) -> Any: 

268 """Retrieve a value, falling back to disk if not in memory. 

269 

270 On a disk hit, the value is promoted back into memory. 

271 

272 Args: 

273 key: Cache key. 

274 default: Fallback value. 

275 

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 

283 

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) 

294 

295 return default 

296 

297 # ── Stats ───────────────────────────────────────────────── 

298 

299 @property 

300 def hit_count(self) -> int: 

301 """Total cache hits since creation or last reset.""" 

302 return self._hit_count 

303 

304 @property 

305 def miss_count(self) -> int: 

306 """Total cache misses since creation or last reset.""" 

307 return self._miss_count 

308 

309 def hit_rate(self) -> float: 

310 """Hit rate as a float between 0.0 and 1.0. 

311 

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 

318 

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 } 

331 

332 def reset_stats(self) -> None: 

333 """Reset hit/miss counters to zero.""" 

334 self._hit_count = 0 

335 self._miss_count = 0 

336 

337 # ── Internal ────────────────────────────────────────────── 

338 

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) 

357 

358 

359# ═══════════════════════════════════════════════════════════════ 

360# Module-level convenience — safe access without circular imports 

361# ═══════════════════════════════════════════════════════════════ 

362 

363def get_cache() -> Optional[CacheService]: 

364 """Get the platform CacheService (if bootstrapped). 

365 

366 Safe to call from anywhere — returns None if the platform 

367 hasn't been bootstrapped yet. 

368 

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