| """ |
| Performance Optimization Middleware |
| Provides caching, compression, and connection pooling |
| """ |
|
|
| import asyncio |
| import hashlib |
| import json |
| import logging |
| import time |
| from typing import Any, Dict, Optional |
| from fastapi import Request, Response |
| from starlette.middleware.base import BaseHTTPMiddleware |
| from collections import OrderedDict |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class LocalCacheFallback: |
| """LRU cache with TTL for Redis fallback scenarios. |
| Backported from SaaS to ensure parity and fix cross-repo test regressions. |
| """ |
|
|
| def __init__(self, max_size: int = 1000, default_ttl: int = 60): |
| self.max_size = max_size |
| self.default_ttl = default_ttl |
| self._cache: OrderedDict[str, Dict[str, Any]] = OrderedDict() |
| self._lock = asyncio.Lock() |
| |
| self.hits = 0 |
| self.misses = 0 |
| self.evictions = 0 |
|
|
| async def get(self, key: str) -> Optional[Any]: |
| async with self._lock: |
| if key not in self._cache: |
| self.misses += 1 |
| return None |
|
|
| entry = self._cache[key] |
|
|
| |
| if time.time() > entry.get("expires_at", 0): |
| del self._cache[key] |
| self.misses += 1 |
| return None |
|
|
| |
| self._cache.move_to_end(key) |
| self.hits += 1 |
| return entry["value"] |
|
|
| async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool: |
| async with self._lock: |
| |
| if len(self._cache) >= self.max_size and key not in self._cache: |
| self._cache.popitem(last=False) |
| self.evictions += 1 |
|
|
| ttl = ttl or self.default_ttl |
| self._cache[key] = { |
| "value": value, |
| "expires_at": time.time() + ttl, |
| "created_at": time.time() |
| } |
| self._cache.move_to_end(key) |
| return True |
|
|
| async def delete(self, key: str) -> bool: |
| async with self._lock: |
| if key in self._cache: |
| del self._cache[key] |
| return True |
| return False |
|
|
| def clear(self): |
| """Clear all cache entries""" |
| self._cache.clear() |
| self.hits = 0 |
| self.misses = 0 |
| self.evictions = 0 |
|
|
| def get_stats(self) -> Dict[str, Any]: |
| """Get cache statistics""" |
| total_requests = self.hits + self.misses |
| hit_rate = (self.hits / total_requests * 100) if total_requests > 0 else 0 |
|
|
| return { |
| "size": len(self._cache), |
| "max_size": self.max_size, |
| "hits": self.hits, |
| "misses": self.misses, |
| "evictions": self.evictions, |
| "hit_rate_percent": round(hit_rate, 2), |
| "usage_percent": round(len(self._cache) / self.max_size * 100, 2) if self.max_size > 0 else 0, |
| "entries": list(self._cache.keys())[-10:] |
| } |
|
|
|
|
| |
| class SimpleCache: |
| """Simple in-memory cache with TTL""" |
|
|
| def __init__(self): |
| self.cache: Dict[str, Dict[str, Any]] = {} |
| self.cleanup_interval = 300 |
| self.last_cleanup = time.time() |
|
|
| def get(self, key: str) -> Optional[Any]: |
| """Get value from cache""" |
| if key in self.cache: |
| entry = self.cache[key] |
| if time.time() < entry["expires_at"]: |
| return entry["value"] |
| else: |
| del self.cache[key] |
| return None |
|
|
| def set(self, key: str, value: Any, ttl: int = 300): |
| """Set value in cache with TTL""" |
| self.cache[key] = { |
| "value": value, |
| "expires_at": time.time() + ttl, |
| "created_at": time.time() |
| } |
| self._cleanup_expired() |
|
|
| def delete(self, key: str): |
| """Delete key from cache""" |
| if key in self.cache: |
| del self.cache[key] |
|
|
| def _cleanup_expired(self): |
| """Remove expired entries""" |
| current_time = time.time() |
| if current_time - self.last_cleanup > self.cleanup_interval: |
| expired_keys = [ |
| key for key, entry in self.cache.items() |
| if current_time > entry["expires_at"] |
| ] |
| for key in expired_keys: |
| del self.cache[key] |
| self.last_cleanup = current_time |
|
|
|
|
| |
| cache = SimpleCache() |
|
|
|
|
| class CacheMiddleware(BaseHTTPMiddleware): |
| """Response caching middleware for GET requests""" |
|
|
| def __init__(self, app, cache_ttl: int = 300): |
| super().__init__(app) |
| self.cache_ttl = cache_ttl |
| |
| self.no_cache_patterns = [ |
| "/api/agent/", |
| "/api/ai/", |
| "/api/workflows/execute", |
| "/api/v1/workflows/execute", |
| "/health", |
| "/metrics" |
| ] |
|
|
| async def dispatch(self, request: Request, call_next): |
| |
| if request.method != "GET": |
| return await call_next(request) |
|
|
| |
| path = str(request.url.path) |
| if any(pattern in path for pattern in self.no_cache_patterns): |
| return await call_next(request) |
|
|
| |
| cache_key = self._generate_cache_key(request) |
|
|
| |
| cached_response = cache.get(cache_key) |
| if cached_response: |
| |
| response = Response( |
| content=cached_response["content"], |
| status_code=cached_response["status_code"], |
| headers=cached_response["headers"], |
| media_type=cached_response.get("media_type", "application/json") |
| ) |
| response.headers["X-Cache"] = "HIT" |
| return response |
|
|
| |
| response = await call_next(request) |
|
|
| |
| if 200 <= response.status_code < 300: |
| |
| response_body = b"" |
| async for chunk in response.body_iterator: |
| response_body += chunk |
|
|
| cache_data = { |
| "content": response_body, |
| "status_code": response.status_code, |
| "headers": dict(response.headers), |
| "media_type": response.media_type |
| } |
|
|
| cache.set(cache_key, cache_data, self.cache_ttl) |
|
|
| |
| new_response = Response( |
| content=response_body, |
| status_code=response.status_code, |
| headers=dict(response.headers), |
| media_type=response.media_type |
| ) |
| new_response.headers["X-Cache"] = "MISS" |
| return new_response |
|
|
| response.headers["X-Cache"] = "SKIP" |
| return response |
|
|
| def _generate_cache_key(self, request: Request) -> str: |
| """Generate cache key for request""" |
| |
| key_data = { |
| "path": str(request.url.path), |
| "query": str(request.url.query), |
| "method": request.method, |
| |
| } |
|
|
| key_str = json.dumps(key_data, sort_keys=True) |
| return f"cache:{hashlib.md5(key_str.encode()).hexdigest()}" |
|
|
|
|
| class CompressionMiddleware(BaseHTTPMiddleware): |
| """Response compression middleware""" |
|
|
| def __init__(self, app, min_size: int = 1024): |
| super().__init__(app) |
| self.min_size = min_size |
|
|
| async def dispatch(self, request: Request, call_next): |
| |
| accept_encoding = request.headers.get("accept-encoding", "") |
| if "gzip" not in accept_encoding.lower(): |
| return await call_next(request) |
|
|
| response = await call_next(request) |
|
|
| |
| content_length = response.headers.get("content-length") |
| if content_length and int(content_length) < self.min_size: |
| return response |
|
|
| |
| content_type = response.headers.get("content-type", "") |
| compressible_types = [ |
| "application/json", |
| "text/html", |
| "text/css", |
| "text/javascript", |
| "application/javascript" |
| ] |
|
|
| if not any(ct in content_type for ct in compressible_types): |
| return response |
|
|
| |
| |
| |
| response.headers["content-encoding"] = "gzip" |
|
|
| return response |
|
|
|
|
| class DatabaseConnectionPool: |
| """Simple database connection pool manager |
| |
| Note: For database connections, SQLAlchemy already handles connection pooling. |
| This class is designed for HTTP client connection pooling for external API calls. |
| """ |
|
|
| def __init__(self, max_connections: int = 10, connection_timeout: float = 30.0): |
| self.max_connections = max_connections |
| self.connection_timeout = connection_timeout |
| self._pool = None |
| self._initialized = False |
|
|
| async def _get_pool(self): |
| """Lazy-initialize HTTP connection pool""" |
| if not self._initialized: |
| import httpx |
|
|
| |
| self._pool = httpx.AsyncClient( |
| limits=httpx.Limits( |
| max_connections=self.max_connections, |
| max_keepalive_connections=self.max_connections // 2 |
| ), |
| timeout=httpx.Timeout(self.connection_timeout), |
| http2=True, |
| ) |
| self._initialized = True |
| logger.info(f"HTTP connection pool initialized: max={self.max_connections} connections") |
|
|
| return self._pool |
|
|
| async def get_connection(self): |
| """Get the HTTP client (uses connection pooling internally)""" |
| pool = await self._get_pool() |
| return pool |
|
|
| async def release_connection(self, connection): |
| """Release is handled automatically by httpx.AsyncClient context manager""" |
| |
| |
| |
| return |
|
|
| async def close(self): |
| """Close the connection pool""" |
| if self._pool and self._initialized: |
| await self._pool.aclose() |
| self._initialized = False |
| logger.info("HTTP connection pool closed") |
|
|
| async def __aenter__(self): |
| """Async context manager support""" |
| await self._get_pool() |
| return self |
|
|
| async def __aexit__(self, exc_type, exc_val, exc_tb): |
| """Clean up on exit""" |
| await self.close() |
|
|
|
|
| class RequestMetricsMiddleware(BaseHTTPMiddleware): |
| """Middleware to collect request metrics""" |
|
|
| def __init__(self, app): |
| super().__init__(app) |
| self.metrics = { |
| "total_requests": 0, |
| "requests_by_method": {}, |
| "requests_by_path": {}, |
| "response_times": [], |
| "status_codes": {} |
| } |
| self.start_time = datetime.now() |
|
|
| async def dispatch(self, request: Request, call_next): |
| start_time = time.time() |
|
|
| |
| self.metrics["total_requests"] += 1 |
|
|
| |
| method = request.method |
| self.metrics["requests_by_method"][method] = \ |
| self.metrics["requests_by_method"].get(method, 0) + 1 |
|
|
| |
| path = str(request.url.path) |
| self.metrics["requests_by_path"][path] = \ |
| self.metrics["requests_by_path"].get(path, 0) + 1 |
|
|
| |
| response = await call_next(request) |
|
|
| |
| response_time = time.time() - start_time |
| self.metrics["response_times"].append(response_time) |
|
|
| |
| status = response.status_code |
| self.metrics["status_codes"][status] = \ |
| self.metrics["status_codes"].get(status, 0) + 1 |
|
|
| |
| response.headers["X-Response-Time"] = f"{response_time:.3f}s" |
|
|
| return response |
|
|
| def get_metrics(self) -> Dict[str, Any]: |
| """Get current metrics""" |
| response_times = self.metrics["response_times"] |
| avg_response_time = sum(response_times) / len(response_times) if response_times else 0 |
|
|
| return { |
| "uptime_seconds": (datetime.now() - self.start_time).total_seconds(), |
| "total_requests": self.metrics["total_requests"], |
| "requests_per_second": self.metrics["total_requests"] / max( |
| (datetime.now() - self.start_time).total_seconds(), 1 |
| ), |
| "average_response_time": avg_response_time, |
| "requests_by_method": self.metrics["requests_by_method"], |
| "top_paths": sorted( |
| self.metrics["requests_by_path"].items(), |
| key=lambda x: x[1], |
| reverse=True |
| )[:10], |
| "status_codes": self.metrics["status_codes"] |
| } |
|
|
|
|
| |
| db_pool = DatabaseConnectionPool() |
|
|
|
|
| def setup_performance_middleware(app): |
| """Setup all performance middleware""" |
| |
| app.add_middleware(RequestMetricsMiddleware) |
| app.add_middleware(CompressionMiddleware) |
| app.add_middleware(CacheMiddleware, cache_ttl=300) |
|
|
|
|
| |
| def cached(ttl: int = 300, key_prefix: str = ""): |
| """Decorator to cache function results""" |
| def decorator(func): |
| @wraps(func) |
| async def wrapper(*args, **kwargs): |
| |
| key_data = { |
| "function": func.__name__, |
| "args": args, |
| "kwargs": kwargs |
| } |
| key_str = f"{key_prefix}:{hashlib.md5(json.dumps(key_data, sort_keys=True, default=str).encode()).hexdigest()}" |
|
|
| |
| result = cache.get(key_str) |
| if result is not None: |
| return result |
|
|
| |
| result = await func(*args, **kwargs) |
| cache.set(key_str, result, ttl) |
| return result |
|
|
| return wrapper |
| return decorator |