initial commit

This commit is contained in:
2025-10-19 22:09:35 +03:00
commit 6d593b4554
114 changed files with 23622 additions and 0 deletions

View 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.

View 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.

View 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.

View 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.