initial commit
This commit is contained in:
191
docs/user-guide/caching/cache-strategies.md
Normal file
191
docs/user-guide/caching/cache-strategies.md
Normal file
@ -0,0 +1,191 @@
|
||||
# Cache Strategies
|
||||
|
||||
Effective cache strategies balance performance gains with data consistency. This section covers invalidation patterns, cache warming, and optimization techniques for building robust caching systems.
|
||||
|
||||
## Cache Invalidation Strategies
|
||||
|
||||
Cache invalidation is one of the hardest problems in computer science. The boilerplate provides several strategies to handle different scenarios while maintaining data consistency.
|
||||
|
||||
### Understanding Cache Invalidation
|
||||
|
||||
**Cache invalidation** ensures that cached data doesn't become stale when the underlying data changes. Poor invalidation leads to users seeing outdated information, while over-aggressive invalidation negates caching benefits.
|
||||
|
||||
### Basic Invalidation Patterns
|
||||
|
||||
#### Time-Based Expiration (TTL)
|
||||
|
||||
The simplest strategy relies on cache expiration times:
|
||||
|
||||
```python
|
||||
# Set different TTL based on data characteristics
|
||||
@cache(key_prefix="user_profile", expiration=3600) # 1 hour for profiles
|
||||
@cache(key_prefix="post_content", expiration=1800) # 30 min for posts
|
||||
@cache(key_prefix="live_stats", expiration=60) # 1 min for live data
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Simple to implement and understand
|
||||
- Guarantees cache freshness within TTL period
|
||||
- Works well for data with predictable change patterns
|
||||
|
||||
**Cons:**
|
||||
|
||||
- May serve stale data until TTL expires
|
||||
- Difficult to optimize TTL for all scenarios
|
||||
- Cache miss storms when many keys expire simultaneously
|
||||
|
||||
#### Write-Through Invalidation
|
||||
|
||||
Automatically invalidate cache when data is modified:
|
||||
|
||||
```python
|
||||
@router.put("/posts/{post_id}")
|
||||
@cache(
|
||||
key_prefix="post_cache",
|
||||
resource_id_name="post_id",
|
||||
to_invalidate_extra={
|
||||
"user_posts": "{user_id}", # User's post list
|
||||
"category_posts": "{category_id}", # Category post list
|
||||
"recent_posts": "global" # Global recent posts
|
||||
}
|
||||
)
|
||||
async def update_post(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
post_data: PostUpdate,
|
||||
user_id: int,
|
||||
category_id: int
|
||||
):
|
||||
# Update triggers automatic cache invalidation
|
||||
updated_post = await crud_posts.update(db=db, id=post_id, object=post_data)
|
||||
return updated_post
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Immediate consistency when data changes
|
||||
- No stale data served to users
|
||||
- Precise control over what gets invalidated
|
||||
|
||||
**Cons:**
|
||||
|
||||
- More complex implementation
|
||||
- Can impact write performance
|
||||
- Risk of over-invalidation
|
||||
|
||||
### Advanced Invalidation Patterns
|
||||
|
||||
#### Pattern-Based Invalidation
|
||||
|
||||
Use Redis pattern matching for bulk invalidation:
|
||||
|
||||
```python
|
||||
@router.put("/users/{user_id}/profile")
|
||||
@cache(
|
||||
key_prefix="user_profile",
|
||||
resource_id_name="user_id",
|
||||
pattern_to_invalidate_extra=[
|
||||
"user_{user_id}_*", # All user-related caches
|
||||
"*_user_{user_id}_*", # Caches containing this user
|
||||
"leaderboard_*", # Leaderboards might change
|
||||
"search_users_*" # User search results
|
||||
]
|
||||
)
|
||||
async def update_user_profile(request: Request, user_id: int, profile_data: ProfileUpdate):
|
||||
await crud_users.update(db=db, id=user_id, object=profile_data)
|
||||
return {"message": "Profile updated"}
|
||||
```
|
||||
|
||||
**Pattern Examples:**
|
||||
```python
|
||||
# User-specific patterns
|
||||
"user_{user_id}_posts_*" # All paginated post lists for user
|
||||
"user_{user_id}_*_cache" # All cached data for user
|
||||
"*_following_{user_id}" # All caches tracking this user's followers
|
||||
|
||||
# Content patterns
|
||||
"posts_category_{category_id}_*" # All posts in category
|
||||
"comments_post_{post_id}_*" # All comments for post
|
||||
"search_*_{query}" # All search results for query
|
||||
|
||||
# Time-based patterns
|
||||
"daily_stats_*" # All daily statistics
|
||||
"hourly_*" # All hourly data
|
||||
"temp_*" # Temporary cache entries
|
||||
```
|
||||
|
||||
## Cache Warming Strategies
|
||||
|
||||
Cache warming proactively loads data into cache to avoid cache misses during peak usage.
|
||||
|
||||
### Application Startup Warming
|
||||
|
||||
```python
|
||||
# core/startup.py
|
||||
async def warm_critical_caches():
|
||||
"""Warm up critical caches during application startup."""
|
||||
|
||||
logger.info("Starting cache warming...")
|
||||
|
||||
# Warm up reference data
|
||||
await warm_reference_data()
|
||||
|
||||
# Warm up popular content
|
||||
await warm_popular_content()
|
||||
|
||||
# Warm up user session data for active users
|
||||
await warm_active_user_data()
|
||||
|
||||
logger.info("Cache warming completed")
|
||||
|
||||
async def warm_reference_data():
|
||||
"""Warm up reference data that rarely changes."""
|
||||
|
||||
# Countries, currencies, timezones, etc.
|
||||
reference_data = await crud_reference.get_all_countries()
|
||||
for country in reference_data:
|
||||
cache_key = f"country:{country['code']}"
|
||||
await cache.client.set(cache_key, json.dumps(country), ex=86400) # 24 hours
|
||||
|
||||
# Categories
|
||||
categories = await crud_categories.get_all()
|
||||
await cache.client.set("all_categories", json.dumps(categories), ex=3600)
|
||||
|
||||
async def warm_popular_content():
|
||||
"""Warm up frequently accessed content."""
|
||||
|
||||
# Most viewed posts
|
||||
popular_posts = await crud_posts.get_popular(limit=100)
|
||||
for post in popular_posts:
|
||||
cache_key = f"post_cache:{post['id']}"
|
||||
await cache.client.set(cache_key, json.dumps(post), ex=1800)
|
||||
|
||||
# Trending topics
|
||||
trending = await crud_posts.get_trending_topics(limit=50)
|
||||
await cache.client.set("trending_topics", json.dumps(trending), ex=600)
|
||||
|
||||
async def warm_active_user_data():
|
||||
"""Warm up data for recently active users."""
|
||||
|
||||
# Get users active in last 24 hours
|
||||
active_users = await crud_users.get_recently_active(hours=24)
|
||||
|
||||
for user in active_users:
|
||||
# Warm user profile
|
||||
profile_key = f"user_profile:{user['id']}"
|
||||
await cache.client.set(profile_key, json.dumps(user), ex=3600)
|
||||
|
||||
# Warm user's recent posts
|
||||
user_posts = await crud_posts.get_user_posts(user['id'], limit=10)
|
||||
posts_key = f"user_{user['id']}_posts:page_1"
|
||||
await cache.client.set(posts_key, json.dumps(user_posts), ex=1800)
|
||||
|
||||
# Add to startup events
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
await create_redis_cache_pool()
|
||||
await warm_critical_caches()
|
||||
```
|
||||
|
||||
These cache strategies provide a comprehensive approach to building performant, consistent caching systems that scale with your application's needs while maintaining data integrity.
|
||||
515
docs/user-guide/caching/client-cache.md
Normal file
515
docs/user-guide/caching/client-cache.md
Normal file
@ -0,0 +1,515 @@
|
||||
# Client Cache
|
||||
|
||||
Client-side caching leverages HTTP cache headers to instruct browsers and CDNs to cache responses locally. This reduces server load and improves user experience by serving cached content directly from the client.
|
||||
|
||||
## Understanding Client Caching
|
||||
|
||||
Client caching works by setting HTTP headers that tell browsers, proxies, and CDNs how long they should cache responses. When implemented correctly, subsequent requests for the same resource are served instantly from the local cache.
|
||||
|
||||
### Benefits of Client Caching
|
||||
|
||||
**Reduced Latency**: Instant response from local cache eliminates network round trips
|
||||
**Lower Server Load**: Fewer requests reach your server infrastructure
|
||||
**Bandwidth Savings**: Cached responses don't consume network bandwidth
|
||||
**Better User Experience**: Faster page loads and improved responsiveness
|
||||
**Cost Reduction**: Lower server resource usage and bandwidth costs
|
||||
|
||||
## Cache-Control Headers
|
||||
|
||||
The `Cache-Control` header is the primary mechanism for controlling client-side caching behavior.
|
||||
|
||||
### Header Components
|
||||
|
||||
```http
|
||||
Cache-Control: public, max-age=3600, s-maxage=7200, must-revalidate
|
||||
```
|
||||
|
||||
**Directive Breakdown:**
|
||||
|
||||
- **`public`**: Response can be cached by any cache (browsers, CDNs, proxies)
|
||||
- **`private`**: Response can only be cached by browsers, not shared caches
|
||||
- **`max-age=3600`**: Cache for 3600 seconds (1 hour) in browsers
|
||||
- **`s-maxage=7200`**: Cache for 7200 seconds (2 hours) in shared caches (CDNs)
|
||||
- **`must-revalidate`**: Must check with server when cache expires
|
||||
- **`no-cache`**: Must revalidate with server before using cached response
|
||||
- **`no-store`**: Must not store any part of the response
|
||||
|
||||
### Common Cache Patterns
|
||||
|
||||
```python
|
||||
# Static assets (images, CSS, JS)
|
||||
"Cache-Control: public, max-age=31536000, immutable" # 1 year
|
||||
|
||||
# API data that changes rarely
|
||||
"Cache-Control: public, max-age=3600" # 1 hour
|
||||
|
||||
# User-specific data
|
||||
"Cache-Control: private, max-age=1800" # 30 minutes, browser only
|
||||
|
||||
# Real-time data
|
||||
"Cache-Control: no-cache, must-revalidate" # Always validate
|
||||
|
||||
# Sensitive data
|
||||
"Cache-Control: no-store, no-cache, must-revalidate" # Never cache
|
||||
```
|
||||
|
||||
## Middleware Implementation
|
||||
|
||||
The boilerplate includes middleware that automatically adds cache headers to responses.
|
||||
|
||||
### ClientCacheMiddleware
|
||||
|
||||
```python
|
||||
# middleware/client_cache_middleware.py
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
|
||||
class ClientCacheMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to set Cache-Control headers for client-side caching."""
|
||||
|
||||
def __init__(self, app: FastAPI, max_age: int = 60) -> None:
|
||||
super().__init__(app)
|
||||
self.max_age = max_age
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
response: Response = await call_next(request)
|
||||
response.headers["Cache-Control"] = f"public, max-age={self.max_age}"
|
||||
return response
|
||||
```
|
||||
|
||||
### Adding Middleware to Application
|
||||
|
||||
```python
|
||||
# main.py
|
||||
from fastapi import FastAPI
|
||||
from app.middleware.client_cache_middleware import ClientCacheMiddleware
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Add client caching middleware
|
||||
app.add_middleware(
|
||||
ClientCacheMiddleware,
|
||||
max_age=300 # 5 minutes default cache
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Middleware Configuration
|
||||
|
||||
```python
|
||||
class AdvancedClientCacheMiddleware(BaseHTTPMiddleware):
|
||||
"""Advanced client cache middleware with path-specific configurations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: FastAPI,
|
||||
default_max_age: int = 300,
|
||||
path_configs: dict[str, dict] = None
|
||||
):
|
||||
super().__init__(app)
|
||||
self.default_max_age = default_max_age
|
||||
self.path_configs = path_configs or {}
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
response = await call_next(request)
|
||||
|
||||
# Get path-specific configuration
|
||||
cache_config = self._get_cache_config(request.url.path)
|
||||
|
||||
# Set cache headers based on configuration
|
||||
if cache_config.get("no_cache", False):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
else:
|
||||
max_age = cache_config.get("max_age", self.default_max_age)
|
||||
visibility = "private" if cache_config.get("private", False) else "public"
|
||||
|
||||
cache_control = f"{visibility}, max-age={max_age}"
|
||||
|
||||
if cache_config.get("must_revalidate", False):
|
||||
cache_control += ", must-revalidate"
|
||||
|
||||
if cache_config.get("immutable", False):
|
||||
cache_control += ", immutable"
|
||||
|
||||
response.headers["Cache-Control"] = cache_control
|
||||
|
||||
return response
|
||||
|
||||
def _get_cache_config(self, path: str) -> dict:
|
||||
"""Get cache configuration for a specific path."""
|
||||
for pattern, config in self.path_configs.items():
|
||||
if path.startswith(pattern):
|
||||
return config
|
||||
return {}
|
||||
|
||||
# Usage with path-specific configurations
|
||||
app.add_middleware(
|
||||
AdvancedClientCacheMiddleware,
|
||||
default_max_age=300,
|
||||
path_configs={
|
||||
"/api/v1/static/": {"max_age": 31536000, "immutable": True}, # 1 year for static assets
|
||||
"/api/v1/auth/": {"no_cache": True}, # No cache for auth endpoints
|
||||
"/api/v1/users/me": {"private": True, "max_age": 900}, # 15 min private cache for user data
|
||||
"/api/v1/public/": {"max_age": 1800}, # 30 min for public data
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Manual Cache Control
|
||||
|
||||
Set cache headers manually in specific endpoints for fine-grained control.
|
||||
|
||||
### Response Header Manipulation
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/v1/static-data")
|
||||
async def get_static_data(response: Response):
|
||||
"""Endpoint with long-term caching for static data."""
|
||||
# Set cache headers for static data
|
||||
response.headers["Cache-Control"] = "public, max-age=86400, immutable" # 24 hours
|
||||
response.headers["Last-Modified"] = "Wed, 21 Oct 2023 07:28:00 GMT"
|
||||
response.headers["ETag"] = '"abc123"'
|
||||
|
||||
return {"data": "static content that rarely changes"}
|
||||
|
||||
@router.get("/api/v1/user-data")
|
||||
async def get_user_data(response: Response, current_user: dict = Depends(get_current_user)):
|
||||
"""Endpoint with private caching for user-specific data."""
|
||||
# Private cache for user-specific data
|
||||
response.headers["Cache-Control"] = "private, max-age=1800" # 30 minutes
|
||||
response.headers["Vary"] = "Authorization" # Cache varies by auth header
|
||||
|
||||
return {"user_id": current_user["id"], "preferences": "user data"}
|
||||
|
||||
@router.get("/api/v1/real-time-data")
|
||||
async def get_real_time_data(response: Response):
|
||||
"""Endpoint that should not be cached."""
|
||||
# Prevent caching for real-time data
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
|
||||
return {"timestamp": datetime.utcnow(), "live_data": "current status"}
|
||||
```
|
||||
|
||||
### Conditional Caching
|
||||
|
||||
Implement conditional caching based on request parameters:
|
||||
|
||||
```python
|
||||
@router.get("/api/v1/posts")
|
||||
async def get_posts(
|
||||
response: Response,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
category: str | None = None,
|
||||
db: Annotated[AsyncSession, Depends(async_get_db)]
|
||||
):
|
||||
"""Conditional caching based on parameters."""
|
||||
|
||||
# Different cache strategies based on parameters
|
||||
if category:
|
||||
# Category-specific data changes less frequently
|
||||
response.headers["Cache-Control"] = "public, max-age=1800" # 30 minutes
|
||||
elif page == 1:
|
||||
# First page cached more aggressively
|
||||
response.headers["Cache-Control"] = "public, max-age=600" # 10 minutes
|
||||
else:
|
||||
# Other pages cached for shorter duration
|
||||
response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes
|
||||
|
||||
# Add ETag for efficient revalidation
|
||||
content_hash = hashlib.md5(f"{page}{per_page}{category}".encode()).hexdigest()
|
||||
response.headers["ETag"] = f'"{content_hash}"'
|
||||
|
||||
posts = await crud_posts.get_multi(
|
||||
db=db,
|
||||
offset=(page - 1) * per_page,
|
||||
limit=per_page,
|
||||
category=category
|
||||
)
|
||||
|
||||
return {"posts": posts, "page": page, "per_page": per_page}
|
||||
```
|
||||
|
||||
## ETag Implementation
|
||||
|
||||
ETags enable efficient cache validation by allowing clients to check if content has changed.
|
||||
|
||||
### ETag Generation
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
def generate_etag(data: Any) -> str:
|
||||
"""Generate ETag from data content."""
|
||||
content = json.dumps(data, sort_keys=True, default=str)
|
||||
return hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
@router.get("/api/v1/users/{user_id}")
|
||||
async def get_user(
|
||||
request: Request,
|
||||
response: Response,
|
||||
user_id: int,
|
||||
db: Annotated[AsyncSession, Depends(async_get_db)]
|
||||
):
|
||||
"""Endpoint with ETag support for efficient caching."""
|
||||
|
||||
user = await crud_users.get(db=db, id=user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Generate ETag from user data
|
||||
etag = generate_etag(user)
|
||||
|
||||
# Check if client has current version
|
||||
if_none_match = request.headers.get("If-None-Match")
|
||||
if if_none_match == f'"{etag}"':
|
||||
# Content hasn't changed, return 304 Not Modified
|
||||
response.status_code = 304
|
||||
return Response(status_code=304)
|
||||
|
||||
# Set ETag and cache headers
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
response.headers["Cache-Control"] = "private, max-age=1800, must-revalidate"
|
||||
|
||||
return user
|
||||
```
|
||||
|
||||
### Last-Modified Headers
|
||||
|
||||
Use Last-Modified headers for time-based cache validation:
|
||||
|
||||
```python
|
||||
@router.get("/api/v1/posts/{post_id}")
|
||||
async def get_post(
|
||||
request: Request,
|
||||
response: Response,
|
||||
post_id: int,
|
||||
db: Annotated[AsyncSession, Depends(async_get_db)]
|
||||
):
|
||||
"""Endpoint with Last-Modified header support."""
|
||||
|
||||
post = await crud_posts.get(db=db, id=post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
# Use post's updated_at timestamp
|
||||
last_modified = post["updated_at"]
|
||||
|
||||
# Check If-Modified-Since header
|
||||
if_modified_since = request.headers.get("If-Modified-Since")
|
||||
if if_modified_since:
|
||||
client_time = datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT")
|
||||
if last_modified <= client_time:
|
||||
response.status_code = 304
|
||||
return Response(status_code=304)
|
||||
|
||||
# Set Last-Modified header
|
||||
response.headers["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
response.headers["Cache-Control"] = "public, max-age=3600, must-revalidate"
|
||||
|
||||
return post
|
||||
```
|
||||
|
||||
## Cache Strategy by Content Type
|
||||
|
||||
Different types of content require different caching strategies.
|
||||
|
||||
### Static Assets
|
||||
|
||||
```python
|
||||
@router.get("/static/{file_path:path}")
|
||||
async def serve_static(response: Response, file_path: str):
|
||||
"""Serve static files with aggressive caching."""
|
||||
|
||||
# Static assets can be cached for a long time
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" # 1 year
|
||||
response.headers["Vary"] = "Accept-Encoding" # Vary by compression
|
||||
|
||||
# Add file-specific ETag based on file modification time
|
||||
file_stat = os.stat(f"static/{file_path}")
|
||||
etag = hashlib.md5(f"{file_path}{file_stat.st_mtime}".encode()).hexdigest()
|
||||
response.headers["ETag"] = f'"{etag}"'
|
||||
|
||||
return FileResponse(f"static/{file_path}")
|
||||
```
|
||||
|
||||
### API Responses
|
||||
|
||||
```python
|
||||
# Reference data (rarely changes)
|
||||
@router.get("/api/v1/countries")
|
||||
async def get_countries(response: Response, db: Annotated[AsyncSession, Depends(async_get_db)]):
|
||||
response.headers["Cache-Control"] = "public, max-age=86400" # 24 hours
|
||||
return await crud_countries.get_all(db=db)
|
||||
|
||||
# User-generated content (moderate changes)
|
||||
@router.get("/api/v1/posts")
|
||||
async def get_posts(response: Response, db: Annotated[AsyncSession, Depends(async_get_db)]):
|
||||
response.headers["Cache-Control"] = "public, max-age=1800" # 30 minutes
|
||||
return await crud_posts.get_multi(db=db, is_deleted=False)
|
||||
|
||||
# Personal data (private caching only)
|
||||
@router.get("/api/v1/users/me/notifications")
|
||||
async def get_notifications(
|
||||
response: Response,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Annotated[AsyncSession, Depends(async_get_db)]
|
||||
):
|
||||
response.headers["Cache-Control"] = "private, max-age=300" # 5 minutes
|
||||
response.headers["Vary"] = "Authorization"
|
||||
return await crud_notifications.get_user_notifications(db=db, user_id=current_user["id"])
|
||||
|
||||
# Real-time data (no caching)
|
||||
@router.get("/api/v1/system/status")
|
||||
async def get_system_status(response: Response):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return {"status": "online", "timestamp": datetime.utcnow()}
|
||||
```
|
||||
|
||||
## Vary Header Usage
|
||||
|
||||
The `Vary` header tells caches which request headers affect the response, enabling proper cache key generation.
|
||||
|
||||
### Common Vary Patterns
|
||||
|
||||
```python
|
||||
# Cache varies by authorization (user-specific content)
|
||||
response.headers["Vary"] = "Authorization"
|
||||
|
||||
# Cache varies by accepted language
|
||||
response.headers["Vary"] = "Accept-Language"
|
||||
|
||||
# Cache varies by compression support
|
||||
response.headers["Vary"] = "Accept-Encoding"
|
||||
|
||||
# Multiple varying headers
|
||||
response.headers["Vary"] = "Authorization, Accept-Language, Accept-Encoding"
|
||||
|
||||
# Example implementation
|
||||
@router.get("/api/v1/dashboard")
|
||||
async def get_dashboard(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Dashboard content that varies by user and language."""
|
||||
|
||||
# Content varies by user (Authorization) and language preference
|
||||
response.headers["Vary"] = "Authorization, Accept-Language"
|
||||
response.headers["Cache-Control"] = "private, max-age=900" # 15 minutes
|
||||
|
||||
language = request.headers.get("Accept-Language", "en")
|
||||
|
||||
dashboard_data = await generate_dashboard(
|
||||
user_id=current_user["id"],
|
||||
language=language
|
||||
)
|
||||
|
||||
return dashboard_data
|
||||
```
|
||||
|
||||
## CDN Integration
|
||||
|
||||
Configure cache headers for optimal CDN performance.
|
||||
|
||||
### CDN-Specific Headers
|
||||
|
||||
```python
|
||||
@router.get("/api/v1/public-content")
|
||||
async def get_public_content(response: Response):
|
||||
"""Content optimized for CDN caching."""
|
||||
|
||||
# Different cache times for browser vs CDN
|
||||
response.headers["Cache-Control"] = "public, max-age=300, s-maxage=3600" # 5 min browser, 1 hour CDN
|
||||
|
||||
# CDN-specific headers (CloudFlare example)
|
||||
response.headers["CF-Cache-Tag"] = "public-content,api-v1" # Cache tags for purging
|
||||
response.headers["CF-Edge-Cache"] = "max-age=86400" # Edge cache for 24 hours
|
||||
|
||||
return await get_public_content_data()
|
||||
```
|
||||
|
||||
### Cache Purging
|
||||
|
||||
Implement cache purging for content updates:
|
||||
|
||||
```python
|
||||
@router.put("/api/v1/posts/{post_id}")
|
||||
async def update_post(
|
||||
response: Response,
|
||||
post_id: int,
|
||||
post_data: PostUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Annotated[AsyncSession, Depends(async_get_db)]
|
||||
):
|
||||
"""Update post and invalidate related caches."""
|
||||
|
||||
# Update the post
|
||||
updated_post = await crud_posts.update(db=db, id=post_id, object=post_data)
|
||||
if not updated_post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
# Set headers to indicate cache invalidation is needed
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["X-Cache-Purge"] = f"post-{post_id},user-{current_user['id']}-posts"
|
||||
|
||||
# In production, trigger CDN purge here
|
||||
# await purge_cdn_cache([f"post-{post_id}", f"user-{current_user['id']}-posts"])
|
||||
|
||||
return updated_post
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Cache Duration Guidelines
|
||||
|
||||
```python
|
||||
# Choose appropriate cache durations based on content characteristics:
|
||||
|
||||
# Static assets (CSS, JS, images with versioning)
|
||||
max_age = 31536000 # 1 year
|
||||
|
||||
# API reference data (countries, categories)
|
||||
max_age = 86400 # 24 hours
|
||||
|
||||
# User-generated content (posts, comments)
|
||||
max_age = 1800 # 30 minutes
|
||||
|
||||
# User-specific data (profiles, preferences)
|
||||
max_age = 900 # 15 minutes
|
||||
|
||||
# Search results
|
||||
max_age = 600 # 10 minutes
|
||||
|
||||
# Real-time data (live scores, chat)
|
||||
max_age = 0 # No caching
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
```python
|
||||
# Never cache sensitive data
|
||||
@router.get("/api/v1/admin/secrets")
|
||||
async def get_secrets(response: Response):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return {"secret": "sensitive_data"}
|
||||
|
||||
# Use private caching for user-specific content
|
||||
@router.get("/api/v1/users/me/private-data")
|
||||
async def get_private_data(response: Response):
|
||||
response.headers["Cache-Control"] = "private, max-age=300, must-revalidate"
|
||||
response.headers["Vary"] = "Authorization"
|
||||
return {"private": "user_data"}
|
||||
```
|
||||
|
||||
Client-side caching, when properly implemented, provides significant performance improvements while maintaining security and data freshness through intelligent cache control strategies.
|
||||
77
docs/user-guide/caching/index.md
Normal file
77
docs/user-guide/caching/index.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Caching
|
||||
|
||||
The boilerplate includes a comprehensive caching system built on Redis that improves performance through server-side caching and client-side cache control. This section covers the complete caching implementation.
|
||||
|
||||
## Overview
|
||||
|
||||
The caching system provides multiple layers of optimization:
|
||||
|
||||
- **Server-Side Caching**: Redis-based caching with automatic invalidation
|
||||
- **Client-Side Caching**: HTTP cache headers for browser optimization
|
||||
- **Cache Invalidation**: Smart invalidation strategies for data consistency
|
||||
|
||||
## Quick Example
|
||||
|
||||
```python
|
||||
from app.core.utils.cache import cache
|
||||
|
||||
@router.get("/posts/{post_id}")
|
||||
@cache(key_prefix="post_cache", expiration=3600)
|
||||
async def get_post(request: Request, post_id: int):
|
||||
# Cached for 1 hour, automatic invalidation on updates
|
||||
return await crud_posts.get(db=db, id=post_id)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server-Side Caching
|
||||
- **Redis Integration**: Connection pooling and async operations
|
||||
- **Decorator-Based**: Simple `@cache` decorator for endpoints
|
||||
- **Smart Invalidation**: Automatic cache clearing on data changes
|
||||
- **Pattern Matching**: Bulk invalidation using Redis patterns
|
||||
|
||||
### Client-Side Caching
|
||||
- **HTTP Headers**: Cache-Control headers for browser caching
|
||||
- **Middleware**: Automatic header injection
|
||||
- **Configurable TTL**: Customizable cache duration
|
||||
|
||||
## Key Features
|
||||
|
||||
**Automatic Cache Management**
|
||||
- Caches GET requests automatically
|
||||
- Invalidates cache on PUT/POST/DELETE operations
|
||||
- Supports complex invalidation patterns
|
||||
|
||||
**Flexible Configuration**
|
||||
- Per-endpoint expiration times
|
||||
- Custom cache key generation
|
||||
- Environment-specific Redis settings
|
||||
|
||||
**Performance Optimization**
|
||||
- Connection pooling for Redis
|
||||
- Efficient key pattern matching
|
||||
- Minimal overhead for cache operations
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **[Redis Cache](redis-cache.md)** - Server-side caching with Redis
|
||||
2. **[Client Cache](client-cache.md)** - Browser caching with HTTP headers
|
||||
3. **[Cache Strategies](cache-strategies.md)** - Invalidation patterns and best practices
|
||||
|
||||
Each section provides detailed implementation examples and configuration options for building a robust caching layer.
|
||||
|
||||
## Configuration
|
||||
|
||||
Basic Redis configuration in your environment:
|
||||
|
||||
```bash
|
||||
# Redis Cache Settings
|
||||
REDIS_CACHE_HOST=localhost
|
||||
REDIS_CACHE_PORT=6379
|
||||
```
|
||||
|
||||
The caching system automatically handles connection pooling and provides efficient cache operations for your FastAPI endpoints.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Start with **[Redis Cache](redis-cache.md)** to understand the core server-side caching implementation, then explore client-side caching and advanced invalidation strategies.
|
||||
359
docs/user-guide/caching/redis-cache.md
Normal file
359
docs/user-guide/caching/redis-cache.md
Normal file
@ -0,0 +1,359 @@
|
||||
# Redis Cache
|
||||
|
||||
Redis-based server-side caching provides fast, in-memory storage for API responses. The boilerplate includes a sophisticated caching decorator that automatically handles cache storage, retrieval, and invalidation.
|
||||
|
||||
## Understanding Redis Caching
|
||||
|
||||
Redis serves as a high-performance cache layer between your API and database. When properly implemented, it can reduce response times from hundreds of milliseconds to single-digit milliseconds by serving data directly from memory.
|
||||
|
||||
### Why Redis?
|
||||
|
||||
**Performance**: In-memory storage provides sub-millisecond data access
|
||||
**Scalability**: Handles thousands of concurrent connections efficiently
|
||||
**Persistence**: Optional data persistence for cache warm-up after restarts
|
||||
**Atomic Operations**: Thread-safe operations for concurrent applications
|
||||
**Pattern Matching**: Advanced key pattern operations for bulk cache invalidation
|
||||
|
||||
## Cache Decorator
|
||||
|
||||
The `@cache` decorator provides a simple interface for adding caching to any FastAPI endpoint.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.utils.cache import cache
|
||||
from app.core.db.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/posts/{post_id}")
|
||||
@cache(key_prefix="post_cache", expiration=3600)
|
||||
async def get_post(request: Request, post_id: int, db: Session = Depends(get_db)):
|
||||
# This function's result will be cached for 1 hour
|
||||
post = await crud_posts.get(db=db, id=post_id)
|
||||
return post
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
|
||||
1. **Cache Check**: On GET requests, checks Redis for existing cached data
|
||||
2. **Cache Miss**: If no cache exists, executes the function and stores the result
|
||||
3. **Cache Hit**: Returns cached data directly, bypassing function execution
|
||||
4. **Invalidation**: Automatically removes cache on non-GET requests (POST, PUT, DELETE)
|
||||
|
||||
### Decorator Parameters
|
||||
|
||||
```python
|
||||
@cache(
|
||||
key_prefix: str, # Cache key prefix
|
||||
resource_id_name: str = None, # Explicit resource ID parameter
|
||||
expiration: int = 3600, # Cache TTL in seconds
|
||||
resource_id_type: type | tuple[type, ...] = int, # Expected ID type
|
||||
to_invalidate_extra: dict[str, str] = None, # Additional keys to invalidate
|
||||
pattern_to_invalidate_extra: list[str] = None # Pattern-based invalidation
|
||||
)
|
||||
```
|
||||
|
||||
#### Key Prefix
|
||||
|
||||
The key prefix creates unique cache identifiers:
|
||||
|
||||
```python
|
||||
# Simple prefix
|
||||
@cache(key_prefix="user_data")
|
||||
# Generates keys like: "user_data:123"
|
||||
|
||||
# Dynamic prefix with placeholders
|
||||
@cache(key_prefix="{username}_posts")
|
||||
# Generates keys like: "johndoe_posts:456"
|
||||
|
||||
# Complex prefix with multiple parameters
|
||||
@cache(key_prefix="user_{user_id}_posts_page_{page}")
|
||||
# Generates keys like: "user_123_posts_page_2:789"
|
||||
```
|
||||
|
||||
#### Resource ID Handling
|
||||
|
||||
```python
|
||||
# Automatic ID inference (looks for 'id' parameter)
|
||||
@cache(key_prefix="post_cache")
|
||||
async def get_post(request: Request, post_id: int):
|
||||
# Uses post_id automatically
|
||||
|
||||
# Explicit ID parameter
|
||||
@cache(key_prefix="user_cache", resource_id_name="username")
|
||||
async def get_user(request: Request, username: str):
|
||||
# Uses username instead of looking for 'id'
|
||||
|
||||
# Multiple ID types
|
||||
@cache(key_prefix="search", resource_id_type=(int, str))
|
||||
async def search(request: Request, query: str, page: int):
|
||||
# Accepts either string or int as resource ID
|
||||
```
|
||||
|
||||
### Advanced Caching Patterns
|
||||
|
||||
#### Paginated Data Caching
|
||||
|
||||
```python
|
||||
@router.get("/users/{username}/posts")
|
||||
@cache(
|
||||
key_prefix="{username}_posts:page_{page}:items_per_page_{items_per_page}",
|
||||
resource_id_name="username",
|
||||
expiration=300 # 5 minutes for paginated data
|
||||
)
|
||||
async def get_user_posts(
|
||||
request: Request,
|
||||
username: str,
|
||||
page: int = 1,
|
||||
items_per_page: int = 10
|
||||
):
|
||||
offset = compute_offset(page, items_per_page)
|
||||
posts = await crud_posts.get_multi(
|
||||
db=db,
|
||||
offset=offset,
|
||||
limit=items_per_page,
|
||||
created_by_user_id=user_id
|
||||
)
|
||||
return paginated_response(posts, page, items_per_page)
|
||||
```
|
||||
|
||||
#### Hierarchical Data Caching
|
||||
|
||||
```python
|
||||
@router.get("/organizations/{org_id}/departments/{dept_id}/employees")
|
||||
@cache(
|
||||
key_prefix="org_{org_id}_dept_{dept_id}_employees",
|
||||
resource_id_name="dept_id",
|
||||
expiration=1800 # 30 minutes
|
||||
)
|
||||
async def get_department_employees(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
dept_id: int
|
||||
):
|
||||
employees = await crud_employees.get_multi(
|
||||
db=db,
|
||||
department_id=dept_id,
|
||||
organization_id=org_id
|
||||
)
|
||||
return employees
|
||||
```
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
Cache invalidation ensures data consistency when the underlying data changes.
|
||||
|
||||
### Automatic Invalidation
|
||||
|
||||
The cache decorator automatically invalidates cache entries on non-GET requests:
|
||||
|
||||
```python
|
||||
@router.put("/posts/{post_id}")
|
||||
@cache(key_prefix="post_cache", resource_id_name="post_id")
|
||||
async def update_post(request: Request, post_id: int, data: PostUpdate):
|
||||
# Automatically invalidates "post_cache:123" when called with PUT/POST/DELETE
|
||||
await crud_posts.update(db=db, id=post_id, object=data)
|
||||
return {"message": "Post updated"}
|
||||
```
|
||||
|
||||
### Extra Key Invalidation
|
||||
|
||||
Invalidate related cache entries when data changes:
|
||||
|
||||
```python
|
||||
@router.post("/posts")
|
||||
@cache(
|
||||
key_prefix="new_post",
|
||||
resource_id_name="user_id",
|
||||
to_invalidate_extra={
|
||||
"user_posts": "{user_id}", # Invalidate user's post list
|
||||
"latest_posts": "global", # Invalidate global latest posts
|
||||
"user_stats": "{user_id}" # Invalidate user statistics
|
||||
}
|
||||
)
|
||||
async def create_post(request: Request, post: PostCreate, user_id: int):
|
||||
# Creating a post invalidates related cached data
|
||||
new_post = await crud_posts.create(db=db, object=post)
|
||||
return new_post
|
||||
```
|
||||
|
||||
### Pattern-Based Invalidation
|
||||
|
||||
Use Redis pattern matching for bulk invalidation:
|
||||
|
||||
```python
|
||||
@router.put("/users/{user_id}/profile")
|
||||
@cache(
|
||||
key_prefix="user_profile",
|
||||
resource_id_name="user_id",
|
||||
pattern_to_invalidate_extra=[
|
||||
"user_{user_id}_*", # All user-related caches
|
||||
"*_user_{user_id}_*", # Caches that include this user
|
||||
"search_results_*" # All search result caches
|
||||
]
|
||||
)
|
||||
async def update_user_profile(request: Request, user_id: int, data: UserUpdate):
|
||||
# Invalidates all matching cache patterns
|
||||
await crud_users.update(db=db, id=user_id, object=data)
|
||||
return {"message": "Profile updated"}
|
||||
```
|
||||
|
||||
**Pattern Examples:**
|
||||
|
||||
- `user_*` - All keys starting with "user_"
|
||||
- `*_posts` - All keys ending with "_posts"
|
||||
- `user_*_posts_*` - Complex patterns with wildcards
|
||||
- `temp_*` - Temporary cache entries
|
||||
|
||||
## Configuration
|
||||
|
||||
### Redis Settings
|
||||
|
||||
Configure Redis connection in your environment settings:
|
||||
|
||||
```python
|
||||
# core/config.py
|
||||
class RedisCacheSettings(BaseSettings):
|
||||
REDIS_CACHE_HOST: str = config("REDIS_CACHE_HOST", default="localhost")
|
||||
REDIS_CACHE_PORT: int = config("REDIS_CACHE_PORT", default=6379)
|
||||
REDIS_CACHE_PASSWORD: str = config("REDIS_CACHE_PASSWORD", default="")
|
||||
REDIS_CACHE_DB: int = config("REDIS_CACHE_DB", default=0)
|
||||
REDIS_CACHE_URL: str = f"redis://:{REDIS_CACHE_PASSWORD}@{REDIS_CACHE_HOST}:{REDIS_CACHE_PORT}/{REDIS_CACHE_DB}"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Basic Configuration
|
||||
REDIS_CACHE_HOST=localhost
|
||||
REDIS_CACHE_PORT=6379
|
||||
|
||||
# Production Configuration
|
||||
REDIS_CACHE_HOST=redis.production.com
|
||||
REDIS_CACHE_PORT=6379
|
||||
REDIS_CACHE_PASSWORD=your-secure-password
|
||||
REDIS_CACHE_DB=0
|
||||
|
||||
# Docker Compose
|
||||
REDIS_CACHE_HOST=redis
|
||||
REDIS_CACHE_PORT=6379
|
||||
```
|
||||
|
||||
### Connection Pool Setup
|
||||
|
||||
The boilerplate automatically configures Redis connection pooling:
|
||||
|
||||
```python
|
||||
# core/setup.py
|
||||
async def create_redis_cache_pool() -> None:
|
||||
"""Initialize Redis connection pool for caching."""
|
||||
cache.pool = redis.ConnectionPool.from_url(
|
||||
settings.REDIS_CACHE_URL,
|
||||
max_connections=20, # Maximum connections in pool
|
||||
retry_on_timeout=True, # Retry on connection timeout
|
||||
socket_timeout=5.0, # Socket timeout in seconds
|
||||
health_check_interval=30 # Health check frequency
|
||||
)
|
||||
cache.client = redis.Redis.from_pool(cache.pool)
|
||||
```
|
||||
|
||||
### Cache Client Usage
|
||||
|
||||
Direct Redis client access for custom caching logic:
|
||||
|
||||
```python
|
||||
from app.core.utils.cache import client
|
||||
|
||||
async def custom_cache_operation():
|
||||
if client is None:
|
||||
raise MissingClientError("Redis client not initialized")
|
||||
|
||||
# Set custom cache entry
|
||||
await client.set("custom_key", "custom_value", ex=3600)
|
||||
|
||||
# Get cached value
|
||||
cached_value = await client.get("custom_key")
|
||||
|
||||
# Delete cache entry
|
||||
await client.delete("custom_key")
|
||||
|
||||
# Bulk operations
|
||||
pipe = client.pipeline()
|
||||
pipe.set("key1", "value1")
|
||||
pipe.set("key2", "value2")
|
||||
pipe.expire("key1", 3600)
|
||||
await pipe.execute()
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
Connection pooling prevents the overhead of creating new Redis connections for each request:
|
||||
|
||||
```python
|
||||
# Benefits of connection pooling:
|
||||
# - Reuses existing connections
|
||||
# - Handles connection failures gracefully
|
||||
# - Provides connection health checks
|
||||
# - Supports concurrent operations
|
||||
|
||||
# Pool configuration
|
||||
redis.ConnectionPool.from_url(
|
||||
settings.REDIS_CACHE_URL,
|
||||
max_connections=20, # Adjust based on expected load
|
||||
retry_on_timeout=True, # Handle network issues
|
||||
socket_keepalive=True, # Keep connections alive
|
||||
socket_keepalive_options={}
|
||||
)
|
||||
```
|
||||
|
||||
### Cache Key Generation
|
||||
|
||||
The cache decorator automatically generates keys using this pattern:
|
||||
|
||||
```python
|
||||
# Decorator generates: "{formatted_key_prefix}:{resource_id}"
|
||||
@cache(key_prefix="post_cache", resource_id_name="post_id")
|
||||
# Generates: "post_cache:123"
|
||||
|
||||
@cache(key_prefix="{username}_posts:page_{page}")
|
||||
# Generates: "johndoe_posts:page_1:456" (where 456 is the resource_id)
|
||||
|
||||
# The system handles key formatting automatically - you just provide the prefix template
|
||||
```
|
||||
|
||||
**What you control:**
|
||||
- `key_prefix` template with placeholders like `{username}`, `{page}`
|
||||
- `resource_id_name` to specify which parameter to use as the ID
|
||||
- The decorator handles the rest
|
||||
|
||||
**Generated key examples from the boilerplate:**
|
||||
```python
|
||||
# From posts.py
|
||||
"{username}_posts:page_{page}:items_per_page_{items_per_page}" → "john_posts:page_1:items_per_page_10:789"
|
||||
"{username}_post_cache" → "john_post_cache:123"
|
||||
```
|
||||
|
||||
### Expiration Strategies
|
||||
|
||||
Choose appropriate expiration times based on data characteristics:
|
||||
|
||||
```python
|
||||
# Static reference data (rarely changes)
|
||||
@cache(key_prefix="countries", expiration=86400) # 24 hours
|
||||
|
||||
# User-generated content (changes moderately)
|
||||
@cache(key_prefix="user_posts", expiration=1800) # 30 minutes
|
||||
|
||||
# Real-time data (changes frequently)
|
||||
@cache(key_prefix="live_stats", expiration=60) # 1 minute
|
||||
|
||||
# Search results (can be stale)
|
||||
@cache(key_prefix="search", expiration=3600) # 1 hour
|
||||
```
|
||||
|
||||
This comprehensive Redis caching system provides high-performance data access while maintaining data consistency through intelligent invalidation strategies.
|
||||
Reference in New Issue
Block a user