Coverage for fastblocks/adapters/templates/_enhanced_cache.py: 74%

290 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 03:37 -0700

1"""Enhanced caching strategies for FastBlocks template rendering. 

2 

3This module provides advanced caching capabilities including: 

4- Intelligent cache warming based on usage patterns 

5- Hierarchical cache invalidation with dependencies 

6- Performance monitoring and analytics 

7- Multi-tier caching with automatic promotion/demotion 

8- Predictive cache preloading 

9 

10Requirements: 

11- acb[cache]>=0.19.0 

12- redis>=4.0.0 (for Redis backend) 

13- asyncio 

14 

15Author: lesleslie <les@wedgwoodwebworks.com> 

16Created: 2025-01-12 

17""" 

18 

19import asyncio 

20import builtins 

21import time 

22from collections import defaultdict, deque 

23from collections.abc import Awaitable, Callable 

24from contextlib import suppress 

25from dataclasses import dataclass, field 

26from enum import Enum 

27from typing import Any 

28from uuid import UUID 

29 

30from acb.depends import depends 

31 

32 

33class CacheTier(Enum): 

34 """Cache tier levels for multi-tier caching.""" 

35 

36 HOT = "hot" # Frequently accessed, in-memory 

37 WARM = "warm" # Moderately accessed, memory/redis hybrid 

38 COLD = "cold" # Rarely accessed, redis only 

39 FROZEN = "frozen" # Archive tier, slow but persistent 

40 

41 

42@dataclass 

43class CacheMetrics: 

44 """Cache performance metrics.""" 

45 

46 hits: int = 0 

47 misses: int = 0 

48 evictions: int = 0 

49 warming_operations: int = 0 

50 invalidations: int = 0 

51 tier_promotions: int = 0 

52 tier_demotions: int = 0 

53 memory_usage: int = 0 

54 

55 @property 

56 def hit_ratio(self) -> float: 

57 """Calculate cache hit ratio.""" 

58 total = self.hits + self.misses 

59 return self.hits / total if total > 0 else 0.0 

60 

61 @property 

62 def efficiency(self) -> float: 

63 """Calculate cache efficiency (hits vs operations).""" 

64 operations = self.hits + self.misses + self.warming_operations 

65 return self.hits / operations if operations > 0 else 0.0 

66 

67 

68@dataclass 

69class CacheEntry: 

70 """Enhanced cache entry with metadata.""" 

71 

72 key: str 

73 value: Any 

74 tier: CacheTier = CacheTier.COLD 

75 created_at: float = field(default_factory=time.time) 

76 last_accessed: float = field(default_factory=time.time) 

77 access_count: int = 0 

78 dependencies: set[str] = field(default_factory=set) 

79 tags: set[str] = field(default_factory=set) 

80 size: int = 0 

81 ttl: int | None = None 

82 

83 @property 

84 def age(self) -> float: 

85 """Age of the cache entry in seconds.""" 

86 return time.time() - self.created_at 

87 

88 @property 

89 def idle_time(self) -> float: 

90 """Time since last access in seconds.""" 

91 return time.time() - self.last_accessed 

92 

93 def touch(self) -> None: 

94 """Update access statistics.""" 

95 self.last_accessed = time.time() 

96 self.access_count += 1 

97 

98 

99@dataclass 

100class CacheStats: 

101 """Comprehensive cache statistics.""" 

102 

103 total_entries: int = 0 

104 tier_distribution: dict[CacheTier, int] = field( 

105 default_factory=lambda: {tier: 0 for tier in CacheTier} 

106 ) 

107 memory_usage_by_tier: dict[CacheTier, int] = field( 

108 default_factory=lambda: {tier: 0 for tier in CacheTier} 

109 ) 

110 metrics: CacheMetrics = field(default_factory=CacheMetrics) 

111 hot_entries: list[str] = field(default_factory=list) 

112 cold_entries: list[str] = field(default_factory=list) 

113 

114 

115class EnhancedCacheManager: 

116 """Enhanced cache manager with intelligent strategies.""" 

117 

118 # Required ACB 0.19.0+ metadata 

119 MODULE_ID: UUID = UUID("01937d89-b123-4567-89ab-123456789def") 

120 MODULE_STATUS: str = "stable" 

121 

122 def __init__( 

123 self, 

124 max_memory_entries: int = 1000, 

125 hot_tier_size: int = 100, 

126 warm_tier_size: int = 300, 

127 promotion_threshold: int = 5, 

128 demotion_idle_time: int = 3600, 

129 ) -> None: 

130 """Initialize enhanced cache manager. 

131 

132 Args: 

133 max_memory_entries: Maximum entries to keep in memory 

134 hot_tier_size: Maximum entries in hot tier 

135 warm_tier_size: Maximum entries in warm tier 

136 promotion_threshold: Access count threshold for promotion 

137 demotion_idle_time: Seconds of idle time before demotion 

138 """ 

139 self.max_memory_entries = max_memory_entries 

140 self.hot_tier_size = hot_tier_size 

141 self.warm_tier_size = warm_tier_size 

142 self.promotion_threshold = promotion_threshold 

143 self.demotion_idle_time = demotion_idle_time 

144 

145 # Internal data structures 

146 self.entries: dict[str, CacheEntry] = {} 

147 self.access_history: deque[tuple[str, float]] = deque(maxlen=10000) 

148 self.dependency_graph: dict[str, set[str]] = defaultdict(set) 

149 self.tag_index: dict[str, set[str]] = defaultdict(set) 

150 self.warming_queue: asyncio.Queue[ 

151 tuple[str, Callable[[str], Awaitable[Any]]] 

152 ] = asyncio.Queue() 

153 

154 # Performance tracking 

155 self.metrics = CacheMetrics() 

156 self.performance_history: deque[dict[str, Any]] = deque(maxlen=1000) 

157 

158 # Background tasks 

159 self._maintenance_task: asyncio.Task[None] | None = None 

160 self._warming_task: asyncio.Task[None] | None = None 

161 

162 # Register with ACB 

163 with suppress(Exception): 

164 depends.set(self) 

165 

166 async def initialize(self) -> None: 

167 """Initialize cache manager and start background tasks.""" 

168 # Start maintenance task 

169 self._maintenance_task = asyncio.create_task(self._maintenance_loop()) 

170 

171 # Start cache warming task 

172 self._warming_task = asyncio.create_task(self._warming_loop()) 

173 

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

175 """Get value from cache with tier management.""" 

176 start_time = time.time() 

177 

178 try: 

179 if key in self.entries: 

180 entry = self.entries[key] 

181 

182 # Check TTL expiration 

183 if self._is_expired(entry): 

184 await self._remove_entry(key) 

185 self.metrics.misses += 1 

186 return default 

187 

188 # Update access statistics 

189 entry.touch() 

190 self.access_history.append((key, time.time())) 

191 

192 # Consider promotion 

193 await self._consider_promotion(entry) 

194 

195 self.metrics.hits += 1 

196 return entry.value 

197 else: 

198 self.metrics.misses += 1 

199 return default 

200 

201 finally: 

202 # Track performance 

203 operation_time = time.time() - start_time 

204 self.performance_history.append( 

205 { 

206 "operation": "get", 

207 "key": key, 

208 "time": operation_time, 

209 "hit": key in self.entries, 

210 "timestamp": time.time(), 

211 } 

212 ) 

213 

214 async def set( 

215 self, 

216 key: str, 

217 value: Any, 

218 ttl: int | None = None, 

219 dependencies: set[str] | None = None, 

220 tags: set[str] | None = None, 

221 tier: CacheTier | None = None, 

222 ) -> None: 

223 """Set value in cache with metadata.""" 

224 # Calculate entry size (approximate) 

225 size = self._calculate_size(value) 

226 

227 # Create cache entry 

228 entry = CacheEntry( 

229 key=key, 

230 value=value, 

231 tier=tier or CacheTier.COLD, 

232 dependencies=dependencies or set(), 

233 tags=tags or set(), 

234 size=size, 

235 ttl=ttl, 

236 ) 

237 

238 # Store entry 

239 self.entries[key] = entry 

240 

241 # Update indexes 

242 self._update_dependency_graph(key, dependencies or set()) 

243 self._update_tag_index(key, tags or set()) 

244 

245 # Manage memory usage 

246 await self._manage_memory() 

247 

248 # Update metrics 

249 self.metrics.memory_usage += size 

250 

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

252 """Delete entry from cache.""" 

253 if key in self.entries: 

254 await self._remove_entry(key) 

255 return True 

256 return False 

257 

258 async def invalidate_by_dependency(self, dependency: str) -> list[str]: 

259 """Invalidate all entries that depend on the given dependency.""" 

260 invalidated = [] 

261 

262 if dependency in self.dependency_graph: 

263 dependent_keys = self.dependency_graph[dependency].copy() 

264 

265 for key in dependent_keys: 

266 if await self.delete(key): 

267 invalidated.append(key) 

268 

269 # Clean up dependency graph 

270 del self.dependency_graph[dependency] 

271 

272 self.metrics.invalidations += len(invalidated) 

273 return invalidated 

274 

275 async def invalidate_by_tags(self, tags: builtins.set[str]) -> list[str]: 

276 """Invalidate all entries with any of the given tags.""" 

277 invalidated = [] 

278 

279 for tag in tags: 

280 if tag in self.tag_index: 

281 tagged_keys = self.tag_index[tag].copy() 

282 

283 for key in tagged_keys: 

284 if key not in invalidated and await self.delete(key): 

285 invalidated.append(key) 

286 

287 self.metrics.invalidations += len(invalidated) 

288 return invalidated 

289 

290 async def warm_cache( 

291 self, keys: list[str], loader_func: Callable[[str], Awaitable[Any]] 

292 ) -> None: 

293 """Warm cache with specified keys using loader function.""" 

294 for key in keys: 

295 if key not in self.entries: 

296 await self.warming_queue.put((key, loader_func)) 

297 

298 self.metrics.warming_operations += len(keys) 

299 

300 async def get_stats(self) -> CacheStats: 

301 """Get comprehensive cache statistics.""" 

302 tier_distribution = {tier: 0 for tier in CacheTier} 

303 memory_usage_by_tier = {tier: 0 for tier in CacheTier} 

304 hot_entries = [] 

305 cold_entries = [] 

306 

307 for entry in self.entries.values(): 

308 tier_distribution[entry.tier] += 1 

309 memory_usage_by_tier[entry.tier] += entry.size 

310 

311 if entry.tier == CacheTier.HOT: 

312 hot_entries.append(entry.key) 

313 elif entry.tier == CacheTier.COLD: 

314 cold_entries.append(entry.key) 

315 

316 return CacheStats( 

317 total_entries=len(self.entries), 

318 tier_distribution=tier_distribution, 

319 memory_usage_by_tier=memory_usage_by_tier, 

320 metrics=self.metrics, 

321 hot_entries=hot_entries[:10], # Top 10 hot entries 

322 cold_entries=cold_entries[:10], # Top 10 cold entries 

323 ) 

324 

325 async def get_performance_report(self) -> dict[str, Any]: 

326 """Get detailed performance report.""" 

327 if not self.performance_history: 

328 return {"message": "No performance data available"} 

329 

330 # Calculate averages 

331 recent_operations = list(self.performance_history)[-100:] # Last 100 operations 

332 avg_get_time = sum( 

333 op["time"] for op in recent_operations if op["operation"] == "get" 

334 ) / max(1, sum(1 for op in recent_operations if op["operation"] == "get")) 

335 

336 # Hit ratio trend 

337 hits = sum(1 for op in recent_operations if op.get("hit")) 

338 hit_ratio = hits / len(recent_operations) if recent_operations else 0 

339 

340 return { 

341 "avg_get_time": avg_get_time, 

342 "hit_ratio": hit_ratio, 

343 "total_operations": len(self.performance_history), 

344 "recent_operations": len(recent_operations), 

345 "cache_efficiency": self.metrics.efficiency, 

346 "memory_usage": self.metrics.memory_usage, 

347 "tier_promotions": self.metrics.tier_promotions, 

348 "tier_demotions": self.metrics.tier_demotions, 

349 } 

350 

351 async def optimize_tiers(self) -> dict[str, int]: 

352 """Optimize cache tiers based on access patterns.""" 

353 promotions = 0 

354 demotions = 0 

355 

356 for entry in list(self.entries.values()): 

357 # Promotion logic 

358 if ( 

359 entry.tier != CacheTier.HOT 

360 and entry.access_count >= self.promotion_threshold 

361 ): 

362 await self._promote_entry(entry) 

363 promotions += 1 

364 

365 # Demotion logic 

366 elif ( 

367 entry.tier != CacheTier.COLD 

368 and entry.idle_time > self.demotion_idle_time 

369 ): 

370 await self._demote_entry(entry) 

371 demotions += 1 

372 

373 self.metrics.tier_promotions += promotions 

374 self.metrics.tier_demotions += demotions 

375 

376 return { 

377 "promotions": promotions, 

378 "demotions": demotions, 

379 "hot_tier_count": sum( 

380 1 for e in self.entries.values() if e.tier == CacheTier.HOT 

381 ), 

382 "warm_tier_count": sum( 

383 1 for e in self.entries.values() if e.tier == CacheTier.WARM 

384 ), 

385 "cold_tier_count": sum( 

386 1 for e in self.entries.values() if e.tier == CacheTier.COLD 

387 ), 

388 } 

389 

390 async def clear(self, pattern: str | None = None) -> int: 

391 """Clear cache entries, optionally matching a pattern.""" 

392 cleared = 0 

393 

394 if pattern: 

395 import re 

396 

397 regex = re.compile(pattern) # REGEX OK: pattern-based cache invalidation 

398 keys_to_remove = [ 

399 key for key in self.entries.keys() if regex.search(key) 

400 ] # REGEX OK: pattern-based cache invalidation 

401 else: 

402 keys_to_remove = list(self.entries.keys()) 

403 

404 for key in keys_to_remove: 

405 if await self.delete(key): 

406 cleared += 1 

407 

408 return cleared 

409 

410 def _calculate_size(self, value: Any) -> int: 

411 """Calculate approximate size of cached value.""" 

412 try: 

413 import sys 

414 

415 return sys.getsizeof(value) 

416 except Exception: 

417 # Fallback estimation 

418 if isinstance(value, str): 

419 return len(value.encode("utf-8")) 

420 elif isinstance(value, list | tuple): 

421 return sum(self._calculate_size(item) for item in value) 

422 elif isinstance(value, dict): 

423 return sum( 

424 self._calculate_size(k) + self._calculate_size(v) 

425 for k, v in value.items() 

426 ) 

427 

428 return 64 # Default estimate 

429 

430 def _is_expired(self, entry: CacheEntry) -> bool: 

431 """Check if cache entry is expired.""" 

432 if entry.ttl is None: 

433 return False 

434 return entry.age > entry.ttl 

435 

436 async def _remove_entry(self, key: str) -> None: 

437 """Remove entry and clean up indexes.""" 

438 if key not in self.entries: 

439 return 

440 

441 entry = self.entries[key] 

442 

443 # Update metrics 

444 self.metrics.memory_usage -= entry.size 

445 self.metrics.evictions += 1 

446 

447 # Clean up indexes 

448 for dep in entry.dependencies: 

449 self.dependency_graph[dep].discard(key) 

450 if not self.dependency_graph[dep]: 

451 del self.dependency_graph[dep] 

452 

453 for tag in entry.tags: 

454 self.tag_index[tag].discard(key) 

455 if not self.tag_index[tag]: 

456 del self.tag_index[tag] 

457 

458 # Remove entry 

459 del self.entries[key] 

460 

461 def _update_dependency_graph( 

462 self, key: str, dependencies: builtins.set[str] 

463 ) -> None: 

464 """Update dependency graph.""" 

465 for dep in dependencies: 

466 self.dependency_graph[dep].add(key) 

467 

468 def _update_tag_index(self, key: str, tags: builtins.set[str]) -> None: 

469 """Update tag index.""" 

470 for tag in tags: 

471 self.tag_index[tag].add(key) 

472 

473 async def _consider_promotion(self, entry: CacheEntry) -> None: 

474 """Consider promoting entry to higher tier.""" 

475 if entry.access_count >= self.promotion_threshold: 

476 await self._promote_entry(entry) 

477 

478 async def _promote_entry(self, entry: CacheEntry) -> None: 

479 """Promote entry to higher tier.""" 

480 if entry.tier == CacheTier.COLD: 

481 entry.tier = CacheTier.WARM 

482 elif entry.tier == CacheTier.WARM: 

483 entry.tier = CacheTier.HOT 

484 # HOT is the highest tier 

485 

486 async def _demote_entry(self, entry: CacheEntry) -> None: 

487 """Demote entry to lower tier.""" 

488 if entry.tier == CacheTier.HOT: 

489 entry.tier = CacheTier.WARM 

490 elif entry.tier == CacheTier.WARM: 

491 entry.tier = CacheTier.COLD 

492 # COLD is the lowest active tier 

493 

494 async def _manage_memory(self) -> None: 

495 """Manage memory usage by evicting entries.""" 

496 if len(self.entries) <= self.max_memory_entries: 

497 return 

498 

499 # Sort entries by tier and access patterns for eviction 

500 entries_by_priority = sorted( 

501 self.entries.values(), 

502 key=lambda e: (e.tier.value, e.access_count, -e.idle_time), 

503 ) 

504 

505 # Evict least important entries 

506 to_evict = len(self.entries) - self.max_memory_entries 

507 for entry in entries_by_priority[:to_evict]: 

508 await self._remove_entry(entry.key) 

509 

510 async def _maintenance_loop(self) -> None: 

511 """Background maintenance loop.""" 

512 while True: 

513 try: 

514 # Remove expired entries 

515 expired_keys = [ 

516 key 

517 for key, entry in self.entries.items() 

518 if self._is_expired(entry) 

519 ] 

520 

521 for key in expired_keys: 

522 await self._remove_entry(key) 

523 

524 # Optimize tiers 

525 await self.optimize_tiers() 

526 

527 # Memory cleanup 

528 await self._manage_memory() 

529 

530 # Sleep for 60 seconds 

531 await asyncio.sleep(60) 

532 

533 except asyncio.CancelledError: 

534 break 

535 except Exception: 

536 # Continue on errors 

537 await asyncio.sleep(60) 

538 

539 async def _warming_loop(self) -> None: 

540 """Background cache warming loop.""" 

541 while True: 

542 try: 

543 # Wait for warming request 

544 key, loader_func = await self.warming_queue.get() 

545 

546 # Check if still needed 

547 if key not in self.entries: 

548 with suppress(Exception): 

549 value = await loader_func(key) 

550 await self.set( 

551 key, 

552 value, 

553 tier=CacheTier.WARM, # Warmed entries start in WARM tier 

554 ) 

555 

556 # Mark task as done 

557 self.warming_queue.task_done() 

558 

559 except asyncio.CancelledError: 

560 break 

561 except Exception: 

562 # Continue on errors 

563 await asyncio.sleep(1) 

564 

565 async def shutdown(self) -> None: 

566 """Shutdown cache manager and cleanup resources.""" 

567 if self._maintenance_task: 

568 self._maintenance_task.cancel() 

569 try: 

570 await self._maintenance_task 

571 except asyncio.CancelledError: 

572 pass 

573 

574 if self._warming_task: 

575 self._warming_task.cancel() 

576 try: 

577 await self._warming_task 

578 except asyncio.CancelledError: 

579 pass 

580 

581 self.entries.clear() 

582 self.dependency_graph.clear() 

583 self.tag_index.clear() 

584 

585 

586# Global enhanced cache manager instance 

587_enhanced_cache = None 

588 

589 

590def get_enhanced_cache() -> EnhancedCacheManager: 

591 """Get global enhanced cache manager instance.""" 

592 global _enhanced_cache 

593 if _enhanced_cache is None: 

594 _enhanced_cache = EnhancedCacheManager() 

595 return _enhanced_cache 

596 

597 

598# ACB 0.19.0+ compatibility 

599__all__ = [ 

600 "EnhancedCacheManager", 

601 "CacheTier", 

602 "CacheEntry", 

603 "CacheStats", 

604 "get_enhanced_cache", 

605]