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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
1"""Enhanced caching strategies for FastBlocks template rendering.
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
10Requirements:
11- acb[cache]>=0.19.0
12- redis>=4.0.0 (for Redis backend)
13- asyncio
15Author: lesleslie <les@wedgwoodwebworks.com>
16Created: 2025-01-12
17"""
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
30from acb.depends import depends
33class CacheTier(Enum):
34 """Cache tier levels for multi-tier caching."""
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
42@dataclass
43class CacheMetrics:
44 """Cache performance metrics."""
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
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
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
68@dataclass
69class CacheEntry:
70 """Enhanced cache entry with metadata."""
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
83 @property
84 def age(self) -> float:
85 """Age of the cache entry in seconds."""
86 return time.time() - self.created_at
88 @property
89 def idle_time(self) -> float:
90 """Time since last access in seconds."""
91 return time.time() - self.last_accessed
93 def touch(self) -> None:
94 """Update access statistics."""
95 self.last_accessed = time.time()
96 self.access_count += 1
99@dataclass
100class CacheStats:
101 """Comprehensive cache statistics."""
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)
115class EnhancedCacheManager:
116 """Enhanced cache manager with intelligent strategies."""
118 # Required ACB 0.19.0+ metadata
119 MODULE_ID: UUID = UUID("01937d89-b123-4567-89ab-123456789def")
120 MODULE_STATUS: str = "stable"
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.
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
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()
154 # Performance tracking
155 self.metrics = CacheMetrics()
156 self.performance_history: deque[dict[str, Any]] = deque(maxlen=1000)
158 # Background tasks
159 self._maintenance_task: asyncio.Task[None] | None = None
160 self._warming_task: asyncio.Task[None] | None = None
162 # Register with ACB
163 with suppress(Exception):
164 depends.set(self)
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())
171 # Start cache warming task
172 self._warming_task = asyncio.create_task(self._warming_loop())
174 async def get(self, key: str, default: Any = None) -> Any:
175 """Get value from cache with tier management."""
176 start_time = time.time()
178 try:
179 if key in self.entries:
180 entry = self.entries[key]
182 # Check TTL expiration
183 if self._is_expired(entry):
184 await self._remove_entry(key)
185 self.metrics.misses += 1
186 return default
188 # Update access statistics
189 entry.touch()
190 self.access_history.append((key, time.time()))
192 # Consider promotion
193 await self._consider_promotion(entry)
195 self.metrics.hits += 1
196 return entry.value
197 else:
198 self.metrics.misses += 1
199 return default
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 )
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)
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 )
238 # Store entry
239 self.entries[key] = entry
241 # Update indexes
242 self._update_dependency_graph(key, dependencies or set())
243 self._update_tag_index(key, tags or set())
245 # Manage memory usage
246 await self._manage_memory()
248 # Update metrics
249 self.metrics.memory_usage += size
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
258 async def invalidate_by_dependency(self, dependency: str) -> list[str]:
259 """Invalidate all entries that depend on the given dependency."""
260 invalidated = []
262 if dependency in self.dependency_graph:
263 dependent_keys = self.dependency_graph[dependency].copy()
265 for key in dependent_keys:
266 if await self.delete(key):
267 invalidated.append(key)
269 # Clean up dependency graph
270 del self.dependency_graph[dependency]
272 self.metrics.invalidations += len(invalidated)
273 return invalidated
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 = []
279 for tag in tags:
280 if tag in self.tag_index:
281 tagged_keys = self.tag_index[tag].copy()
283 for key in tagged_keys:
284 if key not in invalidated and await self.delete(key):
285 invalidated.append(key)
287 self.metrics.invalidations += len(invalidated)
288 return invalidated
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))
298 self.metrics.warming_operations += len(keys)
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 = []
307 for entry in self.entries.values():
308 tier_distribution[entry.tier] += 1
309 memory_usage_by_tier[entry.tier] += entry.size
311 if entry.tier == CacheTier.HOT:
312 hot_entries.append(entry.key)
313 elif entry.tier == CacheTier.COLD:
314 cold_entries.append(entry.key)
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 )
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"}
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"))
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
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 }
351 async def optimize_tiers(self) -> dict[str, int]:
352 """Optimize cache tiers based on access patterns."""
353 promotions = 0
354 demotions = 0
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
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
373 self.metrics.tier_promotions += promotions
374 self.metrics.tier_demotions += demotions
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 }
390 async def clear(self, pattern: str | None = None) -> int:
391 """Clear cache entries, optionally matching a pattern."""
392 cleared = 0
394 if pattern:
395 import re
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())
404 for key in keys_to_remove:
405 if await self.delete(key):
406 cleared += 1
408 return cleared
410 def _calculate_size(self, value: Any) -> int:
411 """Calculate approximate size of cached value."""
412 try:
413 import sys
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 )
428 return 64 # Default estimate
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
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
441 entry = self.entries[key]
443 # Update metrics
444 self.metrics.memory_usage -= entry.size
445 self.metrics.evictions += 1
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]
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]
458 # Remove entry
459 del self.entries[key]
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)
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)
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)
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
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
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
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 )
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)
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 ]
521 for key in expired_keys:
522 await self._remove_entry(key)
524 # Optimize tiers
525 await self.optimize_tiers()
527 # Memory cleanup
528 await self._manage_memory()
530 # Sleep for 60 seconds
531 await asyncio.sleep(60)
533 except asyncio.CancelledError:
534 break
535 except Exception:
536 # Continue on errors
537 await asyncio.sleep(60)
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()
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 )
556 # Mark task as done
557 self.warming_queue.task_done()
559 except asyncio.CancelledError:
560 break
561 except Exception:
562 # Continue on errors
563 await asyncio.sleep(1)
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
574 if self._warming_task:
575 self._warming_task.cancel()
576 try:
577 await self._warming_task
578 except asyncio.CancelledError:
579 pass
581 self.entries.clear()
582 self.dependency_graph.clear()
583 self.tag_index.clear()
586# Global enhanced cache manager instance
587_enhanced_cache = None
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
598# ACB 0.19.0+ compatibility
599__all__ = [
600 "EnhancedCacheManager",
601 "CacheTier",
602 "CacheEntry",
603 "CacheStats",
604 "get_enhanced_cache",
605]