Coverage for jinja2_async_environment / caching / manager.py: 91%

145 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-26 21:26 -0800

1"""Cache manager for dependency injection and centralized cache control.""" 

2 

3import typing as t 

4from contextlib import suppress 

5from types import ModuleType 

6 

7from anyio import Path as AsyncPath 

8 

9from .strategies import AdaptiveCache, CacheWarmer, HierarchicalCache, LFUCache 

10from .typed import TypedCache 

11 

12# Specialized type aliases for different cache types 

13PackageSpecCache = TypedCache[tuple[t.Any, t.Any]] 

14TemplateRootCache = TypedCache[AsyncPath | None] 

15CompilationCache = TypedCache[str] 

16ModuleCache = TypedCache[ModuleType] 

17 

18# Type alias for template cache that can be either TypedCache or HierarchicalCache 

19TemplateCache = TypedCache[AsyncPath | None] | HierarchicalCache[AsyncPath | None] 

20 

21 

22class CacheManager: 

23 """Centralized cache management with dependency injection support. 

24 

25 This manager provides type-safe caches for different use cases while 

26 allowing dependency injection and proper resource management. 

27 """ 

28 

29 _default_instance: "CacheManager | None" = None 

30 

31 def __init__( 

32 self, 

33 package_cache_size: int = 500, 

34 template_cache_size: int = 1000, 

35 compilation_cache_size: int = 2000, 

36 module_cache_size: int = 200, 

37 default_ttl: int = 300, 

38 ): 

39 """Initialize the cache manager. 

40 

41 Args: 

42 package_cache_size: Maximum size for package spec cache 

43 template_cache_size: Maximum size for template root cache 

44 compilation_cache_size: Maximum size for compilation cache 

45 module_cache_size: Maximum size for module import cache 

46 default_ttl: Default TTL for all caches in seconds 

47 """ 

48 # Initialize type-safe caches 

49 self.package_cache: PackageSpecCache = TypedCache( 

50 max_size=package_cache_size, 

51 default_ttl=default_ttl * 6, # Package specs change rarely 

52 ) 

53 

54 self.template_cache: TemplateCache = TypedCache( 

55 max_size=template_cache_size, 

56 default_ttl=default_ttl * 6, # Template roots change rarely 

57 ) 

58 

59 self.compilation_cache: CompilationCache = TypedCache( 

60 max_size=compilation_cache_size, 

61 default_ttl=default_ttl * 2, # Compiled templates may change 

62 ) 

63 

64 self.module_cache: ModuleCache = TypedCache( 

65 max_size=module_cache_size, 

66 default_ttl=default_ttl * 12, # Modules change very rarely 

67 ) 

68 

69 self._default_ttl = default_ttl 

70 

71 def clear_all(self) -> None: 

72 """Clear all caches.""" 

73 self.package_cache.clear() 

74 self.template_cache.clear() 

75 self.compilation_cache.clear() 

76 self.module_cache.clear() 

77 

78 def cleanup_expired(self) -> dict[str, int]: 

79 """Clean up expired entries from all caches. 

80 

81 Returns: 

82 Dictionary with count of expired entries per cache 

83 """ 

84 return { 

85 "package_cache": self.package_cache.cleanup_expired(), 

86 "template_cache": self.template_cache.cleanup_expired(), 

87 "compilation_cache": self.compilation_cache.cleanup_expired(), 

88 "module_cache": self.module_cache.cleanup_expired(), 

89 } 

90 

91 def get_statistics(self) -> dict[str, dict[str, t.Any]]: 

92 """Get statistics for all caches. 

93 

94 Returns: 

95 Dictionary with statistics for each cache 

96 """ 

97 return { 

98 "package_cache": self.package_cache.get_statistics(), 

99 "template_cache": self.template_cache.get_statistics(), 

100 "compilation_cache": self.compilation_cache.get_statistics(), 

101 "module_cache": self.module_cache.get_statistics(), 

102 } 

103 

104 def resize_caches( 

105 self, 

106 package_size: int | None = None, 

107 template_size: int | None = None, 

108 compilation_size: int | None = None, 

109 module_size: int | None = None, 

110 ) -> None: 

111 """Resize cache maximum sizes. 

112 

113 Args: 

114 package_size: New size for package cache (None to keep current) 

115 template_size: New size for template cache (None to keep current) 

116 compilation_size: New size for compilation cache (None to keep current) 

117 module_size: New size for module cache (None to keep current) 

118 """ 

119 if package_size is not None: 

120 self.package_cache.resize(package_size) 

121 if template_size is not None: 

122 self.template_cache.resize(template_size) 

123 if compilation_size is not None: 

124 self.compilation_cache.resize(compilation_size) 

125 if module_size is not None: 

126 self.module_cache.resize(module_size) 

127 

128 @classmethod 

129 def get_default(cls) -> "CacheManager": 

130 """Get the default global cache manager instance. 

131 

132 This provides backward compatibility while allowing dependency injection. 

133 

134 Returns: 

135 Default cache manager instance 

136 """ 

137 if cls._default_instance is None: 

138 cls._default_instance = cls() 

139 return cls._default_instance 

140 

141 @classmethod 

142 def set_default(cls, manager: "CacheManager") -> None: 

143 """Set a new default cache manager instance. 

144 

145 Args: 

146 manager: New cache manager to use as default 

147 """ 

148 cls._default_instance = manager 

149 

150 def create_scoped_manager( 

151 self, 

152 package_ttl: int | None = None, 

153 template_ttl: int | None = None, 

154 compilation_ttl: int | None = None, 

155 module_ttl: int | None = None, 

156 ) -> "CacheManager": 

157 """Create a new cache manager with different TTL settings. 

158 

159 Useful for creating isolated cache environments for testing 

160 or different application contexts. 

161 

162 Args: 

163 package_ttl: TTL for package cache (None to use default) 

164 template_ttl: TTL for template cache (None to use default) 

165 compilation_ttl: TTL for compilation cache (None to use default) 

166 module_ttl: TTL for module cache (None to use default) 

167 

168 Returns: 

169 New cache manager with specified TTL settings 

170 """ 

171 # Use current cache sizes for the scoped manager 

172 stats = self.get_statistics() 

173 

174 manager = CacheManager( 

175 package_cache_size=stats["package_cache"]["max_size"], 

176 template_cache_size=stats["template_cache"]["max_size"], 

177 compilation_cache_size=stats["compilation_cache"]["max_size"], 

178 module_cache_size=stats["module_cache"]["max_size"], 

179 default_ttl=self._default_ttl, 

180 ) 

181 

182 # Override specific TTLs if provided 

183 if package_ttl is not None: 

184 manager.package_cache._default_ttl = package_ttl 

185 if template_ttl is not None: 

186 manager.template_cache._default_ttl = template_ttl 

187 if compilation_ttl is not None: 

188 manager.compilation_cache._default_ttl = compilation_ttl 

189 if module_ttl is not None: 

190 manager.module_cache._default_ttl = module_ttl 

191 

192 return manager 

193 

194 def get_memory_usage_estimate(self) -> dict[str, int]: 

195 """Get rough memory usage estimates for all caches. 

196 

197 Returns: 

198 Dictionary with estimated memory usage per cache in bytes 

199 """ 

200 # Rough estimates based on typical object sizes 

201 AVERAGE_SIZES = { 

202 "package_spec": 200, # Loader + spec objects 

203 "template_root": 100, # Path objects 

204 "compilation": 5000, # Compiled template code 

205 "module": 1000, # Module objects 

206 } 

207 

208 stats = self.get_statistics() 

209 

210 memory_usage = {} 

211 for cache_name in ( 

212 "package_cache", 

213 "template_cache", 

214 "compilation_cache", 

215 "module_cache", 

216 ): 

217 cache_stats = stats.get(cache_name, {}) 

218 if "size" in cache_stats: 

219 size = cache_stats["size"] 

220 else: 

221 # Handle hierarchical cache which might not have direct size 

222 size_obj = getattr(self, cache_name, None) 

223 if size_obj and hasattr(size_obj, "__len__"): 

224 size = len(size_obj) 

225 else: 

226 size = 0 

227 

228 avg_size = AVERAGE_SIZES.get(cache_name.replace("_cache", ""), 100) 

229 memory_usage[cache_name] = size * avg_size 

230 

231 return memory_usage 

232 

233 def get(self, cache_type: str, key: str) -> t.Any: 

234 """Get value from specified cache. 

235 

236 Args: 

237 cache_type: Type of cache ('package', 'template', 'compilation', 'module') 

238 key: Cache key 

239 

240 Returns: 

241 Cached value or None if not found 

242 

243 Raises: 

244 ValueError: If cache_type is not recognized 

245 """ 

246 cache_map = { 

247 "package": self.package_cache, 

248 "template": self.template_cache, 

249 "compilation": self.compilation_cache, 

250 "module": self.module_cache, 

251 } 

252 

253 if cache_type not in cache_map: 

254 raise ValueError(f"Unknown cache type: {cache_type}") 

255 

256 return cache_map[cache_type].get(key) 

257 

258 def set( 

259 self, cache_type: str, key: str, value: t.Any, ttl: int | None = None 

260 ) -> None: 

261 """Set value in specified cache. 

262 

263 Args: 

264 cache_type: Type of cache ('package', 'template', 'compilation', 'module') 

265 key: Cache key 

266 value: Value to store 

267 ttl: Time-to-live in seconds (uses cache default if None) 

268 

269 Raises: 

270 ValueError: If cache_type is not recognized 

271 """ 

272 cache_map = { 

273 "package": self.package_cache, 

274 "template": self.template_cache, 

275 "compilation": self.compilation_cache, 

276 "module": self.module_cache, 

277 } 

278 

279 if cache_type not in cache_map: 

280 raise ValueError(f"Unknown cache type: {cache_type}") 

281 

282 cache_map[cache_type].set(key, value, ttl) 

283 

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

285 """Delete value from specified cache. 

286 

287 Args: 

288 cache_type: Type of cache ('package', 'template', 'compilation', 'module') 

289 key: Cache key 

290 

291 Returns: 

292 True if key was deleted, False if not found 

293 

294 Raises: 

295 ValueError: If cache_type is not recognized 

296 """ 

297 cache_map = { 

298 "package": self.package_cache, 

299 "template": self.template_cache, 

300 "compilation": self.compilation_cache, 

301 "module": self.module_cache, 

302 } 

303 

304 if cache_type not in cache_map: 

305 raise ValueError(f"Unknown cache type: {cache_type}") 

306 

307 return cache_map[cache_type].delete(key) 

308 

309 def create_cache_warmer(self) -> CacheWarmer: 

310 """Create a cache warmer for this cache manager. 

311 

312 Returns: 

313 CacheWarmer instance for preloading caches 

314 """ 

315 return CacheWarmer(self) 

316 

317 def __repr__(self) -> str: 

318 """String representation of cache manager.""" 

319 stats = self.get_statistics() 

320 total_size = sum(cache["size"] for cache in stats.values()) 

321 return f"CacheManager(total_entries={total_size}, caches=4)" 

322 

323 

324class AdvancedCacheManager(CacheManager): 

325 """Enhanced cache manager with advanced strategies and monitoring.""" 

326 

327 # Override template_cache type to accommodate HierarchicalCache 

328 template_cache: "TemplateCache" 

329 

330 def __init__( 

331 self, 

332 strategy: str = "adaptive", # "lru", "lfu", "adaptive", "hierarchical" 

333 package_cache_size: int = 500, 

334 template_cache_size: int = 1000, 

335 compilation_cache_size: int = 2000, 

336 module_cache_size: int = 200, 

337 default_ttl: int = 300, 

338 enable_hierarchical: bool = False, 

339 l1_cache_size: int = 100, 

340 ): 

341 """Initialize advanced cache manager. 

342 

343 Args: 

344 strategy: Cache strategy to use ("lru", "lfu", "adaptive", "hierarchical") 

345 package_cache_size: Size for package cache 

346 template_cache_size: Size for template cache 

347 compilation_cache_size: Size for compilation cache 

348 module_cache_size: Size for module cache 

349 default_ttl: Default TTL in seconds 

350 enable_hierarchical: Enable hierarchical caching for templates 

351 l1_cache_size: L1 cache size for hierarchical mode 

352 """ 

353 # Don't call super().__init__() - we'll create our own caches 

354 self._strategy = strategy 

355 self._enable_hierarchical = enable_hierarchical 

356 self._default_ttl = default_ttl 

357 

358 # Create caches based on strategy 

359 self._create_caches( 

360 strategy, 

361 package_cache_size, 

362 template_cache_size, 

363 compilation_cache_size, 

364 module_cache_size, 

365 default_ttl, 

366 l1_cache_size, 

367 ) 

368 

369 def _create_caches( 

370 self, 

371 strategy: str, 

372 package_size: int, 

373 template_size: int, 

374 compilation_size: int, 

375 module_size: int, 

376 ttl: int, 

377 l1_size: int, 

378 ) -> None: 

379 """Create caches with specified strategy.""" 

380 cache_factory: dict[str, t.Callable[[int, int], TypedCache[t.Any]]] = { 

381 "lru": lambda size, ttl: TypedCache(max_size=size, default_ttl=ttl), 

382 "lfu": lambda size, ttl: LFUCache(max_size=size, default_ttl=ttl), 

383 "adaptive": lambda size, ttl: AdaptiveCache(max_size=size, default_ttl=ttl), 

384 } 

385 

386 factory = cache_factory.get(strategy, cache_factory["lru"]) 

387 

388 # Create standard caches 

389 self.package_cache = factory(package_size, ttl * 6) 

390 self.compilation_cache = factory(compilation_size, ttl * 2) 

391 self.module_cache = factory(module_size, ttl * 12) 

392 

393 # Create template cache (potentially hierarchical) 

394 if self._enable_hierarchical: 

395 self.template_cache = HierarchicalCache( 

396 l1_size=l1_size, l2_size=template_size, l1_ttl=ttl, l2_ttl=ttl * 6 

397 ) 

398 else: 

399 self.template_cache = factory(template_size, ttl * 6) 

400 

401 def get_extended_statistics(self) -> dict[str, t.Any]: 

402 """Get extended statistics for all caches.""" 

403 stats = self.get_statistics() 

404 

405 # Add strategy-specific statistics 

406 extended_stats: dict[str, t.Any] = { 

407 "strategy": self._strategy, 

408 "hierarchical_enabled": self._enable_hierarchical, 

409 "base_stats": stats, 

410 } 

411 

412 # Get advanced statistics from caches that support it 

413 for cache_name, cache in ( 

414 ("package_cache", self.package_cache), 

415 ("template_cache", self.template_cache), 

416 ("compilation_cache", self.compilation_cache), 

417 ("module_cache", self.module_cache), 

418 ): 

419 # Only call methods on objects that actually have them 

420 # Use getattr with default to avoid type checking issues 

421 get_extended_stats = getattr(cache, "get_extended_statistics", None) 

422 if callable(get_extended_stats): 

423 with suppress(Exception): 

424 extended_stats[f"{cache_name}_extended"] = get_extended_stats() 

425 

426 get_strategy_info = getattr(cache, "get_strategy_info", None) 

427 if callable(get_strategy_info): 

428 with suppress(Exception): 

429 extended_stats[f"{cache_name}_strategy"] = get_strategy_info() 

430 

431 return extended_stats 

432 

433 def optimize_caches(self) -> dict[str, t.Any]: 

434 """Perform cache optimization and return results. 

435 

436 Returns: 

437 Dictionary with optimization results 

438 """ 

439 results: dict[str, t.Any] = {} 

440 

441 # Cleanup expired entries 

442 cleanup_results = self.cleanup_expired() 

443 results["cleanup"] = cleanup_results 

444 

445 # Trigger strategy evaluation for adaptive caches 

446 for cache_name, cache in ( 

447 ("package_cache", self.package_cache), 

448 ("template_cache", self.template_cache), 

449 ("compilation_cache", self.compilation_cache), 

450 ("module_cache", self.module_cache), 

451 ): 

452 if isinstance(cache, AdaptiveCache) and hasattr( 

453 cache, "_evaluate_strategy" 

454 ): 

455 old_strategy = getattr(cache, "_strategy", "unknown") 

456 cache._evaluate_strategy() 

457 new_strategy = getattr(cache, "_strategy", "unknown") 

458 results[f"{cache_name}_strategy_change"] = { 

459 "old": old_strategy, 

460 "new": new_strategy, 

461 "changed": old_strategy != new_strategy, 

462 } 

463 

464 return results 

465 

466 def get_memory_efficiency_report(self) -> dict[str, t.Any]: 

467 """Generate detailed memory efficiency report.""" 

468 stats = self.get_extended_statistics() 

469 memory_usage = self.get_memory_usage_estimate() 

470 

471 total_memory = sum(memory_usage.values()) 

472 total_entries = sum(cache["size"] for cache in stats["base_stats"].values()) 

473 

474 # Calculate efficiency metrics 

475 efficiency_report: dict[str, t.Any] = { 

476 "total_memory_bytes": total_memory, 

477 "total_entries": total_entries, 

478 "avg_memory_per_entry": total_memory / total_entries 

479 if total_entries > 0 

480 else 0, 

481 "memory_by_cache": memory_usage, 

482 "cache_utilization": {}, 

483 "recommendations": [], 

484 } 

485 

486 # Calculate utilization per cache 

487 for cache_name, cache_stats in stats["base_stats"].items(): 

488 utilization = cache_stats["fill_ratio"] 

489 efficiency_report["cache_utilization"][cache_name] = { 

490 "fill_ratio": utilization, 

491 "hit_rate": cache_stats["hit_rate"], 

492 "efficiency_score": utilization * cache_stats["hit_rate"], 

493 } 

494 

495 # Generate recommendations 

496 if utilization < 0.3 and cache_stats["size"] > 0: 

497 efficiency_report["recommendations"].append( 

498 f"Consider reducing {cache_name} size (low utilization: {utilization:.1%})" 

499 ) 

500 elif utilization > 0.9: 

501 efficiency_report["recommendations"].append( 

502 f"Consider increasing {cache_name} size (high utilization: {utilization:.1%})" 

503 ) 

504 

505 if cache_stats["hit_rate"] < 0.5 and cache_stats["size"] > 0: 

506 efficiency_report["recommendations"].append( 

507 f"Consider tuning {cache_name} TTL (low hit rate: {cache_stats['hit_rate']:.1%})" 

508 ) 

509 

510 return efficiency_report