Spaces:
Running
Running
| """ | |
| Stale-While-Revalidate Caching Pattern | |
| Prevents the "Thundering Herd" problem where cache expiration causes | |
| 500 simultaneous database hits. | |
| Pattern: | |
| 1. Serve stale data immediately (fast response) | |
| 2. Trigger background refresh (for next user) | |
| 3. No user ever waits for database | |
| Performance: | |
| - All requests: ~5ms (always from cache) | |
| - Background refresh: Async, doesn't block users | |
| - Database protected from traffic spikes | |
| """ | |
| import asyncio | |
| import time | |
| from typing import Optional, Callable, Any | |
| import json | |
| class StaleWhileRevalidate: | |
| """ | |
| Cache with stale-while-revalidate pattern | |
| When cache expires: | |
| - Returns old (stale) data immediately | |
| - Triggers background refresh | |
| - Next user gets fresh data | |
| """ | |
| def __init__(self, redis_client=None): | |
| """ | |
| Initialize cache manager | |
| Args: | |
| redis_client: Optional Redis client | |
| """ | |
| self.redis = redis_client | |
| self.refresh_locks = {} # Prevent duplicate refreshes | |
| async def get_or_fetch( | |
| self, | |
| cache_key: str, | |
| fetch_func: Callable, | |
| ttl: int = 600, | |
| stale_ttl: int = 3600 | |
| ) -> Any: | |
| """ | |
| Get data with stale-while-revalidate pattern | |
| Args: | |
| cache_key: Cache key | |
| fetch_func: Async function to fetch fresh data | |
| ttl: Fresh data TTL (default: 10 minutes) | |
| stale_ttl: Stale data TTL (default: 1 hour) | |
| Returns: | |
| Cached or fresh data | |
| """ | |
| if not self.redis: | |
| # No cache available - fetch directly | |
| return await fetch_func() | |
| try: | |
| # Try to get cached data with metadata | |
| cached_raw = await self.redis.get(cache_key) | |
| if cached_raw: | |
| cached = json.loads(cached_raw) | |
| data = cached.get('data') | |
| timestamp = cached.get('timestamp', 0) | |
| age = time.time() - timestamp | |
| # Fresh data (< TTL): Return immediately | |
| if age < ttl: | |
| return data | |
| # Stale data (TTL < age < stale_ttl): Return + refresh in background | |
| if age < stale_ttl: | |
| # Return stale data immediately (fast!) | |
| # User doesn't wait | |
| # Trigger background refresh (fire-and-forget) | |
| asyncio.create_task( | |
| self._background_refresh(cache_key, fetch_func, ttl, stale_ttl) | |
| ) | |
| return data | |
| # Too stale (> stale_ttl): Fetch fresh data | |
| # This should rarely happen if traffic is consistent | |
| # No cache or too old: Fetch fresh data | |
| return await self._fetch_and_cache(cache_key, fetch_func, ttl, stale_ttl) | |
| except Exception as e: | |
| print(f"Cache error for {cache_key}: {e}") | |
| # On cache failure, fetch directly | |
| return await fetch_func() | |
| async def _background_refresh( | |
| self, | |
| cache_key: str, | |
| fetch_func: Callable, | |
| ttl: int, | |
| stale_ttl: int | |
| ): | |
| """ | |
| Refresh cache in background (doesn't block user request) | |
| """ | |
| # Prevent duplicate refreshes (race condition) | |
| if cache_key in self.refresh_locks: | |
| return # Already refreshing | |
| try: | |
| self.refresh_locks[cache_key] = True | |
| # Fetch fresh data | |
| fresh_data = await fetch_func() | |
| # Update cache | |
| cache_value = { | |
| 'data': fresh_data, | |
| 'timestamp': time.time() | |
| } | |
| await self.redis.setex( | |
| cache_key, | |
| stale_ttl, # Store for stale_ttl duration | |
| json.dumps(cache_value) | |
| ) | |
| except Exception as e: | |
| print(f"Background refresh failed for {cache_key}: {e}") | |
| finally: | |
| self.refresh_locks.pop(cache_key, None) | |
| async def _fetch_and_cache( | |
| self, | |
| cache_key: str, | |
| fetch_func: Callable, | |
| ttl: int, | |
| stale_ttl: int | |
| ) -> Any: | |
| """ | |
| Fetch fresh data and store in cache | |
| """ | |
| fresh_data = await fetch_func() | |
| # Store with metadata | |
| cache_value = { | |
| 'data': fresh_data, | |
| 'timestamp': time.time() | |
| } | |
| try: | |
| await self.redis.setex( | |
| cache_key, | |
| stale_ttl, | |
| json.dumps(cache_value) | |
| ) | |
| except Exception as e: | |
| print(f"Cache write failed for {cache_key}: {e}") | |
| return fresh_data | |
| # Example usage: | |
| """ | |
| # In your API endpoint: | |
| cache = StaleWhileRevalidate(redis_client) | |
| async def fetch_articles_from_db(): | |
| return await db.get_articles('ai', limit=20) | |
| # This always returns quickly: | |
| # - If fresh: from cache (~5ms) | |
| # - If stale: from cache (~5ms) + background refresh | |
| # - If expired: fetch from DB (~50ms) | |
| articles = await cache.get_or_fetch( | |
| cache_key='news:ai:cursor:xyz', | |
| fetch_func=fetch_articles_from_db, | |
| ttl=600, # Fresh for 10 minutes | |
| stale_ttl=3600 # Serve stale for up to 1 hour | |
| ) | |
| """ | |
| # Example timeline: | |
| """ | |
| T=0: Cache miss β Fetch from DB (50ms) β Store in cache | |
| T=300s: User request β Cache hit (5ms) β Fresh data | |
| T=600s: User request β Cache hit (5ms) β Stale data (still valid!) | |
| β Background refresh triggered (user already got response) | |
| T=605s: Background refresh completes β Cache updated | |
| T=610s: Next user β Cache hit (5ms) β Fresh data again! | |
| Result: All users get 5ms responses, DB never overwhelmed! | |
| """ | |