This document explains the performance optimizations implemented in the transcript-create project, including database query optimization, caching strategies, and API response improvements.
- Overview
- Database Query Optimization
- Redis Caching Layer
- API Response Optimization
- Configuration
- Monitoring and Metrics
- Troubleshooting
The performance optimization implementation focuses on three key areas:
- Database Optimization: Strategic indices for hot paths and complex queries
- Caching Layer: Redis-backed caching with intelligent TTLs and invalidation
- Response Optimization: Compression, cache headers, and efficient data transfer
- API p95 latency: < 500ms
- Search results: < 1 second
- Database queries: No slow query warnings (> 100ms)
- Cache hit rate: > 80%
Five strategic indices have been added to optimize the most frequent database operations:
CREATE INDEX jobs_queue_ordering_idx ON jobs(state, priority, created_at);Purpose: Accelerates worker job selection queries Impact: Reduces job queue scan time by 40-60%
CREATE INDEX jobs_pending_idx ON jobs(created_at)
WHERE state IN ('pending', 'downloading');Purpose: Optimizes the worker's hot path for finding next job Impact: Smaller index size, faster scans for active jobs
CREATE INDEX users_email_idx ON users(email);Purpose: Speeds up authentication lookups Impact: 70-90% faster login/session validation
CREATE INDEX events_user_created_idx ON events(user_id, created_at DESC);Purpose: Optimizes quota check queries Impact: 50-80% faster rate limit checks
CREATE INDEX sessions_user_id_idx ON sessions(user_id);Purpose: Enables fast reverse session lookups Impact: Supports efficient session management
Worker job selection:
SELECT * FROM jobs
WHERE state IN ('pending', 'downloading')
ORDER BY priority, created_at
LIMIT 10;Quota checks:
SELECT COUNT(*) FROM events
WHERE user_id = ?
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC');User authentication:
SELECT id FROM users WHERE email = ?;The indices are applied via Alembic migration:
# Run migration
python scripts/run_migrations.py upgrade
# Or with Docker Compose (automatic)
docker compose up migrationsThe caching layer uses Redis as a high-performance in-memory store with the following features:
- Decorator-based: Simple
@cache()decorator for functions - Automatic key generation: Hashes complex arguments
- TTL-based expiration: Different lifetimes for different resource types
- Graceful degradation: Works without Redis (falls back to direct DB queries)
- Pattern invalidation: Bulk cache clearing by key pattern
Default TTL values (configurable via environment variables):
| Resource Type | TTL | Reason |
|---|---|---|
| Video metadata | 5 minutes | Relatively stable, occasional updates |
| Transcript segments | 1 hour | Immutable once created |
| Search results | 10 minutes | Balance freshness vs performance |
| Session data | 1 hour | User-specific, moderate freshness |
from app.cache import cache
from app.settings import settings
@cache(prefix="video", ttl=settings.CACHE_VIDEO_TTL)
def get_video(db, video_id: uuid.UUID):
return db.execute(
text("SELECT * FROM videos WHERE id=:v"),
{"v": str(video_id)}
).mappings().first()from app.cache import invalidate_cache, invalidate_cache_pattern
# Invalidate specific cache entry
invalidate_cache("video", video_id)
# Invalidate all video caches
invalidate_cache_pattern("video:*")
# Invalidate segments for a video
invalidate_cache_pattern(f"segments:{video_id}*")def my_cache_key(*args, **kwargs):
user_id = kwargs.get('user_id')
query = kwargs.get('query')
return f"search:{user_id}:{hashlib.md5(query.encode()).hexdigest()}"
@cache(prefix="custom", ttl=600, key_func=my_cache_key)
def search_with_user_context(db, user_id, query):
# Implementation
passOn resource update:
def update_video(db, video_id, **data):
# Update in database
db.execute(text("UPDATE videos SET ... WHERE id=:v"), {...})
db.commit()
# Invalidate caches
invalidate_cache("video", video_id)
invalidate_cache_pattern(f"segments:{video_id}*")On job completion:
def complete_transcription(db, video_id):
# Mark video as completed
mark_completed(db, video_id)
# Clear any pending caches
invalidate_cache_pattern(f"video:{video_id}*")Automatic gzip compression for responses > 1KB:
- Compression level: 6 (balanced speed/ratio)
- Minimum size: 1024 bytes
- Automatic detection: Only compresses when client accepts gzip
- Smart behavior: Only uses compressed version if smaller
Intelligent caching headers based on endpoint patterns:
| Endpoint Pattern | Cache-Control | Reasoning |
|---|---|---|
/static/* |
public, max-age=31536000, immutable |
Static assets never change |
/videos/{id} |
public, max-age=300, stale-while-revalidate=60 |
Metadata rarely changes |
/videos/{id}/transcript |
public, max-age=3600, stale-while-revalidate=300 |
Immutable once created |
/search |
public, max-age=600, stale-while-revalidate=60 |
Balanced freshness |
/auth/*, /favorites/* |
private, max-age=60 |
User-specific data |
/health, /metrics |
no-store |
Never cache |
Cursor-based pagination schemas added for efficient large dataset navigation:
# Response schema
class PaginatedVideos(BaseModel):
items: List[VideoInfo]
page_info: PageInfo # Contains next_cursor, has_next_page, etc.Benefits:
- No offset/limit performance degradation
- Stable pagination even with inserts/deletes
- Efficient for large datasets
Add to your .env file:
# Redis Configuration
REDIS_URL=redis://localhost:6379/0 # Or redis://redis:6379/0 in Docker
ENABLE_CACHING=true
# Cache TTL Configuration (seconds)
CACHE_DEFAULT_TTL=300 # 5 minutes
CACHE_VIDEO_TTL=300 # 5 minutes
CACHE_TRANSCRIPT_TTL=3600 # 1 hour
CACHE_SEARCH_TTL=600 # 10 minutes
# Metrics
ENABLE_METRICS=trueRedis is automatically started with the stack:
services:
redis:
image: redis:7-alpine
ports:
- '6380:6379'
volumes:
- redis-data:/dataStart the full stack:
docker compose up -dThe caching layer gracefully degrades when Redis is unavailable:
- Set
REDIS_URL=(empty) in.env - Or simply don't start Redis
- Application continues to work, just without caching benefits
New metrics available at /metrics:
Cache metrics:
cache_hits_total{cache_type}- Total cache hits by typecache_misses_total{cache_type}- Total cache misses by typecache_size_bytes- Current cache size in bytescache_keys_total- Total number of keys in cache
Existing metrics:
http_request_duration_seconds- Request latency histogramdb_query_duration_seconds- Database query durationsearch_queries_total- Search query counter
Get real-time cache statistics:
from app.cache import get_cache_stats
stats = get_cache_stats()
# Returns:
# {
# "available": True,
# "used_memory": "1.5M",
# "total_keys": 42,
# "connected_clients": 5,
# "uptime_seconds": 3600,
# "hit_rate": 0.85
# }Calculate cache effectiveness:
hit_rate = cache_hits_total / (cache_hits_total + cache_misses_total)Target: > 80% hit rate for optimal performance
Import the provided Grafana dashboard for visualization:
- Cache hit/miss rates over time
- API latency percentiles (p50, p95, p99)
- Database query duration
- Resource-specific cache performance
Symptoms: Cache hit rate < 50%
Possible causes:
- TTLs too short
- High traffic with cold cache
- Frequent cache invalidation
Solutions:
# Increase TTLs
CACHE_VIDEO_TTL=600
CACHE_TRANSCRIPT_TTL=7200
# Check invalidation patterns in logs
docker compose logs api | grep "Cache invalidated"Symptoms: Log messages about Redis connection failures
Solutions:
# Check Redis is running
docker compose ps redis
# Check Redis logs
docker compose logs redis
# Restart Redis
docker compose restart redis
# Test connection manually
redis-cli -h localhost -p 6379 pingMonitor Redis memory:
redis-cli info memorySet memory limit in docker-compose.yml:
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lruCheck if indices are being used:
EXPLAIN ANALYZE
SELECT * FROM jobs
WHERE state IN ('pending', 'downloading')
ORDER BY priority, created_at
LIMIT 10;Look for "Index Scan" in the output.
Force index rebuild if needed:
REINDEX INDEX jobs_queue_ordering_idx;Symptoms: Sudden spike in cache misses and database queries
Cause: Multiple requests hitting expired cache simultaneously
Solution: Use stale-while-revalidate headers (already implemented)
Use locust or k6 for load testing:
# locustfile.py
from locust import HttpUser, task, between
class TranscriptUser(HttpUser):
wait_time = between(1, 3)
@task(3)
def get_video(self):
self.client.get("/videos/123e4567-e89b-12d3-a456-426614174000")
@task(2)
def get_transcript(self):
self.client.get("/videos/123e4567-e89b-12d3-a456-426614174000/transcript")
@task(1)
def search(self):
self.client.get("/search?q=example&limit=50")Run test:
locust -f locustfile.py --host=http://localhost:8000Before optimization:
- p95 latency: ~800ms
- Search queries: 1.5-2s
- Cache hit rate: N/A (no caching)
After optimization (expected):
- p95 latency: <500ms ✅
- Search queries: <1s ✅
- Cache hit rate: >80% ✅
- Set appropriate TTLs: Balance freshness with performance
- Invalidate strategically: Clear caches only when data actually changes
- Monitor cache hit rates: Aim for >80%
- Use compression: Enabled by default for all responses >1KB
- Leverage browser caching: Cache-Control headers set automatically
- Index new query patterns: Add indices for new hot paths
- Test with production-like data: Ensure indices are effective
- Monitor memory usage: Set Redis memory limits
- Use cursor pagination: For large result sets
- Profile slow queries: Use EXPLAIN ANALYZE regularly