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

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

2

3 import typing as t

4 from contextlib import suppress

5 from types import ModuleType

6

7 from anyio import Path as AsyncPath

8

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

10 from .typed import TypedCache

11

12# Specialized type aliases for different cache types

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

14 TemplateRootCache = TypedCache[AsyncPath | None]

15 CompilationCache = TypedCache[str]

16 ModuleCache = TypedCache[ModuleType]

17

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

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

20

21

22 class 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

324 class 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