anderson-ufrj
commited on
Commit
·
190953c
1
Parent(s):
e29e8bd
feat(performance): implement advanced compression and connection pooling
Browse files- Create compression service with multiple algorithms (gzip, brotli, zstd)
- Add streaming compression middleware for SSE/WebSocket
- Implement connection pool management service
- Add dynamic pool sizing and health monitoring
- Create admin APIs for compression and pool management
- Add compression metrics and optimization suggestions
- Configure connection recycling and pre-ping
- Add comprehensive documentation for both features
- docs/CONNECTION_POOLING.md +240 -0
- src/api/app.py +43 -11
- src/api/middleware/compression.py +15 -25
- src/api/middleware/streaming_compression.py +230 -0
- src/api/routes/admin/compression.py +193 -0
- src/api/routes/admin/connection_pools.py +313 -0
- src/core/config.py +10 -0
- src/db/session.py +68 -0
- src/services/compression_service.py +485 -0
- src/services/connection_pool_service.py +519 -0
- tests/unit/middleware/test_compression.py +213 -0
docs/CONNECTION_POOLING.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Connection Pool Management Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
The Cidadão.AI backend uses advanced connection pooling for both PostgreSQL and Redis to ensure optimal performance and resource utilization.
|
| 6 |
+
|
| 7 |
+
## Features
|
| 8 |
+
|
| 9 |
+
- **Dynamic Pool Sizing**: Automatically adjusts pool sizes based on usage patterns
|
| 10 |
+
- **Health Monitoring**: Real-time health checks for all connections
|
| 11 |
+
- **Performance Metrics**: Detailed statistics on connection usage
|
| 12 |
+
- **Read Replica Support**: Automatic routing of read-only queries
|
| 13 |
+
- **Connection Recycling**: Prevents stale connections and memory leaks
|
| 14 |
+
|
| 15 |
+
## Database Connection Pools
|
| 16 |
+
|
| 17 |
+
### Configuration
|
| 18 |
+
|
| 19 |
+
Default PostgreSQL pool settings:
|
| 20 |
+
|
| 21 |
+
```python
|
| 22 |
+
{
|
| 23 |
+
"pool_size": 10, # Base number of connections
|
| 24 |
+
"max_overflow": 20, # Additional connections when needed
|
| 25 |
+
"pool_timeout": 30, # Seconds to wait for connection
|
| 26 |
+
"pool_recycle": 3600, # Recycle connections after 1 hour
|
| 27 |
+
"pool_pre_ping": True, # Test connections before use
|
| 28 |
+
"pool_use_lifo": True # LIFO for better cache locality
|
| 29 |
+
}
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### Usage
|
| 33 |
+
|
| 34 |
+
The system automatically manages database connections:
|
| 35 |
+
|
| 36 |
+
```python
|
| 37 |
+
# Automatic connection pooling
|
| 38 |
+
async with get_session() as session:
|
| 39 |
+
# Your database operations
|
| 40 |
+
result = await session.execute(query)
|
| 41 |
+
|
| 42 |
+
# Read-only queries use replica pool if available
|
| 43 |
+
async with get_session(read_only=True) as session:
|
| 44 |
+
# Queries routed to read replica
|
| 45 |
+
data = await session.execute(select_query)
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## Redis Connection Pools
|
| 49 |
+
|
| 50 |
+
### Configuration
|
| 51 |
+
|
| 52 |
+
Default Redis pool settings:
|
| 53 |
+
|
| 54 |
+
```python
|
| 55 |
+
{
|
| 56 |
+
"max_connections": 10,
|
| 57 |
+
"socket_keepalive": True,
|
| 58 |
+
"retry_on_timeout": True,
|
| 59 |
+
"health_check_interval": 30
|
| 60 |
+
}
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Multiple Pools
|
| 64 |
+
|
| 65 |
+
The system maintains separate pools for different purposes:
|
| 66 |
+
|
| 67 |
+
- **Main Pool**: General purpose operations
|
| 68 |
+
- **Cache Pool**: High-throughput caching with larger pool size
|
| 69 |
+
|
| 70 |
+
## Monitoring
|
| 71 |
+
|
| 72 |
+
### API Endpoints
|
| 73 |
+
|
| 74 |
+
Monitor connection pools through admin API:
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
# Get pool statistics
|
| 78 |
+
GET /api/v1/admin/connection-pools/stats
|
| 79 |
+
|
| 80 |
+
# Check pool health
|
| 81 |
+
GET /api/v1/admin/connection-pools/health
|
| 82 |
+
|
| 83 |
+
# Get optimization suggestions
|
| 84 |
+
GET /api/v1/admin/connection-pools/optimize
|
| 85 |
+
|
| 86 |
+
# Get current configurations
|
| 87 |
+
GET /api/v1/admin/connection-pools/config
|
| 88 |
+
|
| 89 |
+
# Reset statistics
|
| 90 |
+
POST /api/v1/admin/connection-pools/reset-stats
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### Key Metrics
|
| 94 |
+
|
| 95 |
+
1. **Active Connections**: Currently in-use connections
|
| 96 |
+
2. **Peak Connections**: Maximum concurrent connections
|
| 97 |
+
3. **Wait Time**: Average time waiting for connections
|
| 98 |
+
4. **Connection Errors**: Failed connection attempts
|
| 99 |
+
5. **Recycle Rate**: How often connections are recycled
|
| 100 |
+
|
| 101 |
+
### Example Response
|
| 102 |
+
|
| 103 |
+
```json
|
| 104 |
+
{
|
| 105 |
+
"database_pools": {
|
| 106 |
+
"main": {
|
| 107 |
+
"active_connections": 5,
|
| 108 |
+
"peak_connections": 12,
|
| 109 |
+
"connections_created": 15,
|
| 110 |
+
"connections_closed": 3,
|
| 111 |
+
"average_wait_time": 0.02,
|
| 112 |
+
"pool_size": 10,
|
| 113 |
+
"overflow": 2
|
| 114 |
+
}
|
| 115 |
+
},
|
| 116 |
+
"redis_pools": {
|
| 117 |
+
"cache": {
|
| 118 |
+
"in_use_connections": 3,
|
| 119 |
+
"available_connections": 7,
|
| 120 |
+
"created_connections": 10
|
| 121 |
+
}
|
| 122 |
+
},
|
| 123 |
+
"recommendations": [
|
| 124 |
+
{
|
| 125 |
+
"pool": "db_main",
|
| 126 |
+
"issue": "High wait times",
|
| 127 |
+
"suggestion": "Increase pool_size to 15"
|
| 128 |
+
}
|
| 129 |
+
]
|
| 130 |
+
}
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
## Optimization
|
| 134 |
+
|
| 135 |
+
### Automatic Optimization
|
| 136 |
+
|
| 137 |
+
The system provides optimization suggestions based on:
|
| 138 |
+
|
| 139 |
+
- **Usage Patterns**: Adjusts pool sizes based on peak usage
|
| 140 |
+
- **Wait Times**: Recommends increases when waits are detected
|
| 141 |
+
- **Error Rates**: Alerts on connection stability issues
|
| 142 |
+
- **Idle Connections**: Suggests reductions for underutilized pools
|
| 143 |
+
|
| 144 |
+
### Manual Tuning
|
| 145 |
+
|
| 146 |
+
Environment variables for fine-tuning:
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
# Database pools
|
| 150 |
+
DATABASE_POOL_SIZE=20
|
| 151 |
+
DATABASE_POOL_OVERFLOW=30
|
| 152 |
+
DATABASE_POOL_TIMEOUT=30
|
| 153 |
+
DATABASE_POOL_RECYCLE=3600
|
| 154 |
+
|
| 155 |
+
# Redis pools
|
| 156 |
+
REDIS_POOL_SIZE=15
|
| 157 |
+
REDIS_MAX_CONNECTIONS=50
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
## Best Practices
|
| 161 |
+
|
| 162 |
+
1. **Monitor Regularly**: Check pool stats during peak hours
|
| 163 |
+
2. **Set Appropriate Sizes**: Start conservative and increase based on metrics
|
| 164 |
+
3. **Use Read Replicas**: Route read-only queries to reduce main DB load
|
| 165 |
+
4. **Enable Pre-ping**: Ensures connections are valid before use
|
| 166 |
+
5. **Configure Recycling**: Prevents long-lived connections from degrading
|
| 167 |
+
|
| 168 |
+
## Troubleshooting
|
| 169 |
+
|
| 170 |
+
### High Wait Times
|
| 171 |
+
|
| 172 |
+
**Symptoms**: Slow response times, timeout errors
|
| 173 |
+
|
| 174 |
+
**Solutions**:
|
| 175 |
+
- Increase `pool_size` or `max_overflow`
|
| 176 |
+
- Check for long-running queries blocking connections
|
| 177 |
+
- Verify database server capacity
|
| 178 |
+
|
| 179 |
+
### Connection Errors
|
| 180 |
+
|
| 181 |
+
**Symptoms**: Intermittent failures, connection refused
|
| 182 |
+
|
| 183 |
+
**Solutions**:
|
| 184 |
+
- Check database server health
|
| 185 |
+
- Verify network connectivity
|
| 186 |
+
- Review firewall/security group rules
|
| 187 |
+
- Check connection limits on database server
|
| 188 |
+
|
| 189 |
+
### Memory Issues
|
| 190 |
+
|
| 191 |
+
**Symptoms**: Growing memory usage over time
|
| 192 |
+
|
| 193 |
+
**Solutions**:
|
| 194 |
+
- Enable connection recycling
|
| 195 |
+
- Reduce pool sizes if over-provisioned
|
| 196 |
+
- Check for connection leaks in application code
|
| 197 |
+
|
| 198 |
+
## Performance Impact
|
| 199 |
+
|
| 200 |
+
Proper connection pooling provides:
|
| 201 |
+
|
| 202 |
+
- **50-70% reduction** in connection overhead
|
| 203 |
+
- **Sub-millisecond** connection acquisition
|
| 204 |
+
- **Better resource utilization** on database server
|
| 205 |
+
- **Improved application scalability**
|
| 206 |
+
|
| 207 |
+
## Monitoring Script
|
| 208 |
+
|
| 209 |
+
Use this script to monitor pools:
|
| 210 |
+
|
| 211 |
+
```python
|
| 212 |
+
import asyncio
|
| 213 |
+
from src.services.connection_pool_service import connection_pool_service
|
| 214 |
+
|
| 215 |
+
async def monitor_pools():
|
| 216 |
+
while True:
|
| 217 |
+
stats = await connection_pool_service.get_pool_stats()
|
| 218 |
+
|
| 219 |
+
# Alert on issues
|
| 220 |
+
for rec in stats["recommendations"]:
|
| 221 |
+
if rec["severity"] == "high":
|
| 222 |
+
print(f"ALERT: {rec['pool']} - {rec['issue']}")
|
| 223 |
+
|
| 224 |
+
# Log metrics
|
| 225 |
+
for name, pool in stats["database_pools"].items():
|
| 226 |
+
print(f"{name}: {pool['active_connections']}/{pool['pool_size']}")
|
| 227 |
+
|
| 228 |
+
await asyncio.sleep(60) # Check every minute
|
| 229 |
+
|
| 230 |
+
asyncio.run(monitor_pools())
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
## Integration with Other Services
|
| 234 |
+
|
| 235 |
+
Connection pools integrate with:
|
| 236 |
+
|
| 237 |
+
- **Cache Warming**: Pre-establishes connections
|
| 238 |
+
- **Health Checks**: Validates pool health
|
| 239 |
+
- **Metrics**: Exports pool statistics to Prometheus
|
| 240 |
+
- **Alerts**: Triggers alerts on pool issues
|
src/api/app.py
CHANGED
|
@@ -69,16 +69,26 @@ async def lifespan(app: FastAPI):
|
|
| 69 |
# Setup HTTP metrics
|
| 70 |
setup_http_metrics()
|
| 71 |
|
| 72 |
-
# Initialize
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
yield
|
| 78 |
|
| 79 |
# Shutdown
|
| 80 |
logger.info("cidadao_ai_api_shutting_down")
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
# Log shutdown event
|
| 83 |
await audit_logger.log_event(
|
| 84 |
event_type=AuditEventType.SYSTEM_SHUTDOWN,
|
|
@@ -89,10 +99,9 @@ async def lifespan(app: FastAPI):
|
|
| 89 |
# Shutdown observability
|
| 90 |
tracing_manager.shutdown()
|
| 91 |
|
| 92 |
-
#
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
# - Clean up cache
|
| 96 |
|
| 97 |
|
| 98 |
# Create FastAPI application
|
|
@@ -175,12 +184,21 @@ app.add_middleware(MetricsMiddleware)
|
|
| 175 |
from src.api.middleware.compression import add_compression_middleware
|
| 176 |
add_compression_middleware(
|
| 177 |
app,
|
| 178 |
-
minimum_size=
|
| 179 |
-
gzip_level=
|
| 180 |
-
brotli_quality=
|
| 181 |
exclude_paths={"/health", "/metrics", "/health/metrics", "/api/v1/ws", "/api/v1/observability"}
|
| 182 |
)
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
# Add IP whitelist middleware (only in production)
|
| 185 |
if settings.is_production or settings.app_env == "staging":
|
| 186 |
app.add_middleware(
|
|
@@ -407,6 +425,8 @@ app.include_router(
|
|
| 407 |
from src.api.routes.admin import ip_whitelist as admin_ip_whitelist
|
| 408 |
from src.api.routes.admin import cache_warming as admin_cache_warming
|
| 409 |
from src.api.routes.admin import database_optimization as admin_db_optimization
|
|
|
|
|
|
|
| 410 |
from src.api.routes import api_keys
|
| 411 |
|
| 412 |
app.include_router(
|
|
@@ -427,6 +447,18 @@ app.include_router(
|
|
| 427 |
tags=["Admin - Database Optimization"]
|
| 428 |
)
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
app.include_router(
|
| 431 |
api_keys.router,
|
| 432 |
prefix="/api/v1",
|
|
|
|
| 69 |
# Setup HTTP metrics
|
| 70 |
setup_http_metrics()
|
| 71 |
|
| 72 |
+
# Initialize connection pools
|
| 73 |
+
from src.db.session import init_database
|
| 74 |
+
await init_database()
|
| 75 |
+
|
| 76 |
+
# Initialize cache warming scheduler
|
| 77 |
+
from src.services.cache_warming_service import cache_warming_service
|
| 78 |
+
warming_task = asyncio.create_task(cache_warming_service.start_warming_scheduler())
|
| 79 |
|
| 80 |
yield
|
| 81 |
|
| 82 |
# Shutdown
|
| 83 |
logger.info("cidadao_ai_api_shutting_down")
|
| 84 |
|
| 85 |
+
# Stop cache warming
|
| 86 |
+
warming_task.cancel()
|
| 87 |
+
try:
|
| 88 |
+
await warming_task
|
| 89 |
+
except asyncio.CancelledError:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
# Log shutdown event
|
| 93 |
await audit_logger.log_event(
|
| 94 |
event_type=AuditEventType.SYSTEM_SHUTDOWN,
|
|
|
|
| 99 |
# Shutdown observability
|
| 100 |
tracing_manager.shutdown()
|
| 101 |
|
| 102 |
+
# Close database connections
|
| 103 |
+
from src.db.session import close_database
|
| 104 |
+
await close_database()
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
# Create FastAPI application
|
|
|
|
| 184 |
from src.api.middleware.compression import add_compression_middleware
|
| 185 |
add_compression_middleware(
|
| 186 |
app,
|
| 187 |
+
minimum_size=settings.compression_min_size,
|
| 188 |
+
gzip_level=settings.compression_gzip_level,
|
| 189 |
+
brotli_quality=settings.compression_brotli_quality,
|
| 190 |
exclude_paths={"/health", "/metrics", "/health/metrics", "/api/v1/ws", "/api/v1/observability"}
|
| 191 |
)
|
| 192 |
|
| 193 |
+
# Add streaming compression middleware
|
| 194 |
+
from src.api.middleware.streaming_compression import StreamingCompressionMiddleware
|
| 195 |
+
app.add_middleware(
|
| 196 |
+
StreamingCompressionMiddleware,
|
| 197 |
+
minimum_size=256,
|
| 198 |
+
compression_level=settings.compression_gzip_level,
|
| 199 |
+
chunk_size=8192
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
# Add IP whitelist middleware (only in production)
|
| 203 |
if settings.is_production or settings.app_env == "staging":
|
| 204 |
app.add_middleware(
|
|
|
|
| 425 |
from src.api.routes.admin import ip_whitelist as admin_ip_whitelist
|
| 426 |
from src.api.routes.admin import cache_warming as admin_cache_warming
|
| 427 |
from src.api.routes.admin import database_optimization as admin_db_optimization
|
| 428 |
+
from src.api.routes.admin import compression as admin_compression
|
| 429 |
+
from src.api.routes.admin import connection_pools as admin_conn_pools
|
| 430 |
from src.api.routes import api_keys
|
| 431 |
|
| 432 |
app.include_router(
|
|
|
|
| 447 |
tags=["Admin - Database Optimization"]
|
| 448 |
)
|
| 449 |
|
| 450 |
+
app.include_router(
|
| 451 |
+
admin_compression.router,
|
| 452 |
+
prefix="/api/v1/admin",
|
| 453 |
+
tags=["Admin - Compression"]
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
app.include_router(
|
| 457 |
+
admin_conn_pools.router,
|
| 458 |
+
prefix="/api/v1/admin",
|
| 459 |
+
tags=["Admin - Connection Pools"]
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
app.include_router(
|
| 463 |
api_keys.router,
|
| 464 |
prefix="/api/v1",
|
src/api/middleware/compression.py
CHANGED
|
@@ -17,6 +17,7 @@ from starlette.datastructures import MutableHeaders
|
|
| 17 |
|
| 18 |
from src.core import get_logger
|
| 19 |
from src.core.json_utils import dumps_bytes, loads
|
|
|
|
| 20 |
|
| 21 |
try:
|
| 22 |
import brotli
|
|
@@ -124,26 +125,15 @@ class CompressionMiddleware(BaseHTTPMiddleware):
|
|
| 124 |
async for chunk in response.body_iterator:
|
| 125 |
body += chunk
|
| 126 |
|
| 127 |
-
#
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
headers=dict(response.headers),
|
| 134 |
-
media_type=response.media_type
|
| 135 |
-
)
|
| 136 |
|
| 137 |
-
#
|
| 138 |
-
if
|
| 139 |
-
# Brotli typically achieves better compression
|
| 140 |
-
compressed_body = self._compress_brotli(body)
|
| 141 |
-
encoding = "br"
|
| 142 |
-
elif accepts_gzip:
|
| 143 |
-
compressed_body = self._compress_gzip(body)
|
| 144 |
-
encoding = "gzip"
|
| 145 |
-
else:
|
| 146 |
-
# Should not reach here, but just in case
|
| 147 |
return Response(
|
| 148 |
content=body,
|
| 149 |
status_code=response.status_code,
|
|
@@ -151,12 +141,12 @@ class CompressionMiddleware(BaseHTTPMiddleware):
|
|
| 151 |
media_type=response.media_type
|
| 152 |
)
|
| 153 |
|
| 154 |
-
#
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
|
| 161 |
# Update headers
|
| 162 |
headers = MutableHeaders(response.headers)
|
|
|
|
| 17 |
|
| 18 |
from src.core import get_logger
|
| 19 |
from src.core.json_utils import dumps_bytes, loads
|
| 20 |
+
from src.services.compression_service import compression_service, CompressionAlgorithm
|
| 21 |
|
| 22 |
try:
|
| 23 |
import brotli
|
|
|
|
| 125 |
async for chunk in response.body_iterator:
|
| 126 |
body += chunk
|
| 127 |
|
| 128 |
+
# Use compression service for optimal compression
|
| 129 |
+
compressed_body, encoding, metrics = compression_service.compress(
|
| 130 |
+
data=body,
|
| 131 |
+
content_type=response.media_type or "application/octet-stream",
|
| 132 |
+
accept_encoding=accept_encoding
|
| 133 |
+
)
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
# If no compression applied, return original
|
| 136 |
+
if encoding == "identity":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
return Response(
|
| 138 |
content=body,
|
| 139 |
status_code=response.status_code,
|
|
|
|
| 141 |
media_type=response.media_type
|
| 142 |
)
|
| 143 |
|
| 144 |
+
# Log compression metrics
|
| 145 |
+
if metrics.get("ratio"):
|
| 146 |
+
logger.debug(
|
| 147 |
+
f"Compressed response with {encoding}: {metrics['original_size']} → {metrics['compressed_size']} bytes "
|
| 148 |
+
f"({metrics['ratio']:.1%} reduction, {metrics.get('compression_time_ms', 0):.1f}ms)"
|
| 149 |
+
)
|
| 150 |
|
| 151 |
# Update headers
|
| 152 |
headers = MutableHeaders(response.headers)
|
src/api/middleware/streaming_compression.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module: api.middleware.streaming_compression
|
| 3 |
+
Description: Compression middleware for streaming responses (SSE, WebSocket)
|
| 4 |
+
Author: Anderson H. Silva
|
| 5 |
+
Date: 2025-01-25
|
| 6 |
+
License: Proprietary - All rights reserved
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import gzip
|
| 10 |
+
import asyncio
|
| 11 |
+
from typing import AsyncIterator, Optional
|
| 12 |
+
from io import BytesIO
|
| 13 |
+
|
| 14 |
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
| 15 |
+
from starlette.responses import StreamingResponse
|
| 16 |
+
|
| 17 |
+
from src.core import get_logger
|
| 18 |
+
|
| 19 |
+
logger = get_logger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class GzipStream:
|
| 23 |
+
"""Streaming gzip compressor."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, level: int = 6):
|
| 26 |
+
self.level = level
|
| 27 |
+
self._buffer = BytesIO()
|
| 28 |
+
self._gzip = gzip.GzipFile(
|
| 29 |
+
fileobj=self._buffer,
|
| 30 |
+
mode='wb',
|
| 31 |
+
compresslevel=level
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def compress(self, data: bytes) -> bytes:
|
| 35 |
+
"""Compress chunk of data."""
|
| 36 |
+
self._gzip.write(data)
|
| 37 |
+
self._gzip.flush()
|
| 38 |
+
|
| 39 |
+
# Get compressed data
|
| 40 |
+
self._buffer.seek(0)
|
| 41 |
+
compressed = self._buffer.read()
|
| 42 |
+
|
| 43 |
+
# Reset buffer
|
| 44 |
+
self._buffer.seek(0)
|
| 45 |
+
self._buffer.truncate()
|
| 46 |
+
|
| 47 |
+
return compressed
|
| 48 |
+
|
| 49 |
+
def close(self) -> bytes:
|
| 50 |
+
"""Finish compression and get final data."""
|
| 51 |
+
self._gzip.close()
|
| 52 |
+
self._buffer.seek(0)
|
| 53 |
+
return self._buffer.read()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class StreamingCompressionMiddleware:
|
| 57 |
+
"""
|
| 58 |
+
Middleware for compressing streaming responses.
|
| 59 |
+
|
| 60 |
+
Handles:
|
| 61 |
+
- Server-Sent Events (SSE)
|
| 62 |
+
- Large file downloads
|
| 63 |
+
- Chunked responses
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
def __init__(
|
| 67 |
+
self,
|
| 68 |
+
app: ASGIApp,
|
| 69 |
+
minimum_size: int = 256,
|
| 70 |
+
compression_level: int = 6,
|
| 71 |
+
chunk_size: int = 8192
|
| 72 |
+
):
|
| 73 |
+
self.app = app
|
| 74 |
+
self.minimum_size = minimum_size
|
| 75 |
+
self.compression_level = compression_level
|
| 76 |
+
self.chunk_size = chunk_size
|
| 77 |
+
|
| 78 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 79 |
+
if scope["type"] != "http":
|
| 80 |
+
await self.app(scope, receive, send)
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
# Check accept-encoding
|
| 84 |
+
headers = dict(scope.get("headers", []))
|
| 85 |
+
accept_encoding = headers.get(b"accept-encoding", b"").decode().lower()
|
| 86 |
+
|
| 87 |
+
if "gzip" not in accept_encoding:
|
| 88 |
+
await self.app(scope, receive, send)
|
| 89 |
+
return
|
| 90 |
+
|
| 91 |
+
# Intercept send
|
| 92 |
+
compressor = None
|
| 93 |
+
content_type = None
|
| 94 |
+
should_compress = False
|
| 95 |
+
|
| 96 |
+
async def wrapped_send(message: Message) -> None:
|
| 97 |
+
nonlocal compressor, content_type, should_compress
|
| 98 |
+
|
| 99 |
+
if message["type"] == "http.response.start":
|
| 100 |
+
# Check content type
|
| 101 |
+
headers_dict = dict(message.get("headers", []))
|
| 102 |
+
content_type = headers_dict.get(b"content-type", b"").decode()
|
| 103 |
+
|
| 104 |
+
# Determine if we should compress
|
| 105 |
+
if self._should_compress_stream(content_type):
|
| 106 |
+
should_compress = True
|
| 107 |
+
compressor = GzipStream(self.compression_level)
|
| 108 |
+
|
| 109 |
+
# Update headers
|
| 110 |
+
new_headers = []
|
| 111 |
+
for name, value in message.get("headers", []):
|
| 112 |
+
# Skip content-length for streaming
|
| 113 |
+
if name.lower() not in (b"content-length", b"content-encoding"):
|
| 114 |
+
new_headers.append((name, value))
|
| 115 |
+
|
| 116 |
+
new_headers.extend([
|
| 117 |
+
(b"content-encoding", b"gzip"),
|
| 118 |
+
(b"vary", b"Accept-Encoding")
|
| 119 |
+
])
|
| 120 |
+
|
| 121 |
+
message["headers"] = new_headers
|
| 122 |
+
|
| 123 |
+
logger.debug(
|
| 124 |
+
"streaming_compression_enabled",
|
| 125 |
+
content_type=content_type
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
elif message["type"] == "http.response.body" and should_compress:
|
| 129 |
+
body = message.get("body", b"")
|
| 130 |
+
more_body = message.get("more_body", False)
|
| 131 |
+
|
| 132 |
+
if body:
|
| 133 |
+
# Compress chunk
|
| 134 |
+
compressed = compressor.compress(body)
|
| 135 |
+
message["body"] = compressed
|
| 136 |
+
|
| 137 |
+
if not more_body and compressor:
|
| 138 |
+
# Final chunk - close compressor
|
| 139 |
+
final_data = compressor.close()
|
| 140 |
+
if final_data:
|
| 141 |
+
# Send final compressed data
|
| 142 |
+
await send({
|
| 143 |
+
"type": "http.response.body",
|
| 144 |
+
"body": final_data,
|
| 145 |
+
"more_body": True
|
| 146 |
+
})
|
| 147 |
+
compressor = None
|
| 148 |
+
|
| 149 |
+
await send(message)
|
| 150 |
+
|
| 151 |
+
await self.app(scope, receive, wrapped_send)
|
| 152 |
+
|
| 153 |
+
def _should_compress_stream(self, content_type: str) -> bool:
|
| 154 |
+
"""Check if streaming content should be compressed."""
|
| 155 |
+
content_type = content_type.lower()
|
| 156 |
+
|
| 157 |
+
# Always compress SSE
|
| 158 |
+
if "text/event-stream" in content_type:
|
| 159 |
+
return True
|
| 160 |
+
|
| 161 |
+
# Compress JSON streams
|
| 162 |
+
if "application/json" in content_type and "stream" in content_type:
|
| 163 |
+
return True
|
| 164 |
+
|
| 165 |
+
# Compress text streams
|
| 166 |
+
if content_type.startswith("text/") and "stream" in content_type:
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
# Compress CSV exports
|
| 170 |
+
if "text/csv" in content_type:
|
| 171 |
+
return True
|
| 172 |
+
|
| 173 |
+
# Compress NDJSON (newline-delimited JSON)
|
| 174 |
+
if "application/x-ndjson" in content_type:
|
| 175 |
+
return True
|
| 176 |
+
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def compress_streaming_response(
|
| 181 |
+
response_iterator: AsyncIterator[str],
|
| 182 |
+
content_type: str = "text/plain",
|
| 183 |
+
encoding: str = "gzip"
|
| 184 |
+
) -> StreamingResponse:
|
| 185 |
+
"""
|
| 186 |
+
Create a compressed streaming response.
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
response_iterator: Async iterator yielding response chunks
|
| 190 |
+
content_type: Content type of response
|
| 191 |
+
encoding: Compression encoding (only gzip supported currently)
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
StreamingResponse with compression
|
| 195 |
+
"""
|
| 196 |
+
async def compressed_iterator():
|
| 197 |
+
compressor = GzipStream()
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
async for chunk in response_iterator:
|
| 201 |
+
if isinstance(chunk, str):
|
| 202 |
+
chunk = chunk.encode('utf-8')
|
| 203 |
+
|
| 204 |
+
compressed = compressor.compress(chunk)
|
| 205 |
+
if compressed:
|
| 206 |
+
yield compressed
|
| 207 |
+
|
| 208 |
+
# Yield final compressed data
|
| 209 |
+
final = compressor.close()
|
| 210 |
+
if final:
|
| 211 |
+
yield final
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error(
|
| 215 |
+
"streaming_compression_error",
|
| 216 |
+
error=str(e),
|
| 217 |
+
exc_info=True
|
| 218 |
+
)
|
| 219 |
+
raise
|
| 220 |
+
|
| 221 |
+
headers = {
|
| 222 |
+
"Content-Type": content_type,
|
| 223 |
+
"Content-Encoding": encoding,
|
| 224 |
+
"Vary": "Accept-Encoding"
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return StreamingResponse(
|
| 228 |
+
compressed_iterator(),
|
| 229 |
+
headers=headers
|
| 230 |
+
)
|
src/api/routes/admin/compression.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module: api.routes.admin.compression
|
| 3 |
+
Description: Admin routes for compression monitoring and configuration
|
| 4 |
+
Author: Anderson H. Silva
|
| 5 |
+
Date: 2025-01-25
|
| 6 |
+
License: Proprietary - All rights reserved
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 10 |
+
|
| 11 |
+
from src.core import get_logger
|
| 12 |
+
from src.api.dependencies import require_admin
|
| 13 |
+
from src.services.compression_service import compression_service
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/compression", tags=["Admin - Compression"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/metrics")
|
| 21 |
+
async def get_compression_metrics(
|
| 22 |
+
admin_user=Depends(require_admin)
|
| 23 |
+
):
|
| 24 |
+
"""
|
| 25 |
+
Get compression metrics and statistics.
|
| 26 |
+
|
| 27 |
+
Requires admin privileges.
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
metrics = compression_service.get_metrics()
|
| 31 |
+
|
| 32 |
+
# Calculate bandwidth savings
|
| 33 |
+
if metrics["total_bytes_saved"] > 0:
|
| 34 |
+
# Assume average bandwidth cost of $0.09 per GB
|
| 35 |
+
gb_saved = metrics["total_bytes_saved"] / (1024 ** 3)
|
| 36 |
+
estimated_savings = gb_saved * 0.09
|
| 37 |
+
|
| 38 |
+
metrics["bandwidth_savings"] = {
|
| 39 |
+
"gb_saved": round(gb_saved, 2),
|
| 40 |
+
"estimated_cost_savings_usd": round(estimated_savings, 2)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return metrics
|
| 44 |
+
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error(
|
| 47 |
+
"compression_metrics_error",
|
| 48 |
+
error=str(e),
|
| 49 |
+
exc_info=True
|
| 50 |
+
)
|
| 51 |
+
raise HTTPException(
|
| 52 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 53 |
+
detail="Failed to get compression metrics"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/optimize")
|
| 58 |
+
async def get_optimization_suggestions(
|
| 59 |
+
admin_user=Depends(require_admin)
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Get compression optimization suggestions.
|
| 63 |
+
|
| 64 |
+
Requires admin privileges.
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
optimization = compression_service.optimize_settings()
|
| 68 |
+
|
| 69 |
+
logger.info(
|
| 70 |
+
"admin_compression_optimization_requested",
|
| 71 |
+
admin=admin_user.get("email"),
|
| 72 |
+
suggestions_count=len(optimization["suggestions"])
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
return optimization
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(
|
| 79 |
+
"compression_optimization_error",
|
| 80 |
+
error=str(e),
|
| 81 |
+
exc_info=True
|
| 82 |
+
)
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 85 |
+
detail="Failed to get optimization suggestions"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/algorithms")
|
| 90 |
+
async def get_available_algorithms(
|
| 91 |
+
admin_user=Depends(require_admin)
|
| 92 |
+
):
|
| 93 |
+
"""
|
| 94 |
+
Get available compression algorithms.
|
| 95 |
+
|
| 96 |
+
Requires admin privileges.
|
| 97 |
+
"""
|
| 98 |
+
algorithms = {
|
| 99 |
+
"gzip": {
|
| 100 |
+
"available": True,
|
| 101 |
+
"description": "Standard gzip compression",
|
| 102 |
+
"levels": "1-9",
|
| 103 |
+
"pros": ["Universal support", "Good compression ratio"],
|
| 104 |
+
"cons": ["Slower than newer algorithms"]
|
| 105 |
+
},
|
| 106 |
+
"deflate": {
|
| 107 |
+
"available": True,
|
| 108 |
+
"description": "Raw deflate compression",
|
| 109 |
+
"levels": "1-9",
|
| 110 |
+
"pros": ["Widely supported", "Fast"],
|
| 111 |
+
"cons": ["Slightly worse ratio than gzip"]
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
# Check Brotli
|
| 116 |
+
try:
|
| 117 |
+
import brotli
|
| 118 |
+
algorithms["br"] = {
|
| 119 |
+
"available": True,
|
| 120 |
+
"description": "Google's Brotli compression",
|
| 121 |
+
"levels": "0-11",
|
| 122 |
+
"pros": ["Best compression ratio", "Good for text"],
|
| 123 |
+
"cons": ["Slower compression", "Less browser support"]
|
| 124 |
+
}
|
| 125 |
+
except ImportError:
|
| 126 |
+
algorithms["br"] = {
|
| 127 |
+
"available": False,
|
| 128 |
+
"description": "Google's Brotli compression",
|
| 129 |
+
"install": "pip install brotli"
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
# Check Zstandard
|
| 133 |
+
try:
|
| 134 |
+
import zstandard
|
| 135 |
+
algorithms["zstd"] = {
|
| 136 |
+
"available": True,
|
| 137 |
+
"description": "Facebook's Zstandard compression",
|
| 138 |
+
"levels": "1-22",
|
| 139 |
+
"pros": ["Very fast", "Good ratio", "Streaming support"],
|
| 140 |
+
"cons": ["Limited browser support"]
|
| 141 |
+
}
|
| 142 |
+
except ImportError:
|
| 143 |
+
algorithms["zstd"] = {
|
| 144 |
+
"available": False,
|
| 145 |
+
"description": "Facebook's Zstandard compression",
|
| 146 |
+
"install": "pip install zstandard"
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"algorithms": algorithms,
|
| 151 |
+
"recommended": "br" if algorithms["br"]["available"] else "gzip"
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.get("/test")
|
| 156 |
+
async def test_compression(
|
| 157 |
+
text: str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 100,
|
| 158 |
+
admin_user=Depends(require_admin)
|
| 159 |
+
):
|
| 160 |
+
"""
|
| 161 |
+
Test compression with sample text.
|
| 162 |
+
|
| 163 |
+
Requires admin privileges.
|
| 164 |
+
"""
|
| 165 |
+
test_data = text.encode('utf-8')
|
| 166 |
+
results = {}
|
| 167 |
+
|
| 168 |
+
# Test different algorithms
|
| 169 |
+
for accept_encoding in ["gzip", "br", "zstd", "deflate", "gzip, br"]:
|
| 170 |
+
compressed, encoding, metrics = compression_service.compress(
|
| 171 |
+
data=test_data,
|
| 172 |
+
content_type="text/plain",
|
| 173 |
+
accept_encoding=accept_encoding
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
if encoding != "identity":
|
| 177 |
+
results[accept_encoding] = {
|
| 178 |
+
"encoding_used": encoding,
|
| 179 |
+
"original_size": len(test_data),
|
| 180 |
+
"compressed_size": len(compressed),
|
| 181 |
+
"compression_ratio": f"{metrics.get('ratio', 0):.1%}",
|
| 182 |
+
"time_ms": f"{metrics.get('compression_time_ms', 0):.2f}",
|
| 183 |
+
"throughput_mbps": f"{metrics.get('throughput_mbps', 0):.1f}"
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
"test_results": results,
|
| 188 |
+
"test_data_info": {
|
| 189 |
+
"content": text[:50] + "...",
|
| 190 |
+
"size_bytes": len(test_data),
|
| 191 |
+
"content_type": "text/plain"
|
| 192 |
+
}
|
| 193 |
+
}
|
src/api/routes/admin/connection_pools.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module: api.routes.admin.connection_pools
|
| 3 |
+
Description: Admin routes for connection pool management
|
| 4 |
+
Author: Anderson H. Silva
|
| 5 |
+
Date: 2025-01-25
|
| 6 |
+
License: Proprietary - All rights reserved
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Dict, Any, Optional
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 11 |
+
|
| 12 |
+
from src.core import get_logger
|
| 13 |
+
from src.api.dependencies import require_admin
|
| 14 |
+
from src.services.connection_pool_service import connection_pool_service
|
| 15 |
+
|
| 16 |
+
logger = get_logger(__name__)
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/connection-pools", tags=["Admin - Connection Pools"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("/stats")
|
| 22 |
+
async def get_connection_pool_stats(
|
| 23 |
+
admin_user=Depends(require_admin)
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Get connection pool statistics.
|
| 27 |
+
|
| 28 |
+
Requires admin privileges.
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
stats = await connection_pool_service.get_pool_stats()
|
| 32 |
+
|
| 33 |
+
# Add summary
|
| 34 |
+
total_db_connections = sum(
|
| 35 |
+
pool.get("active_connections", 0)
|
| 36 |
+
for pool in stats["database_pools"].values()
|
| 37 |
+
)
|
| 38 |
+
total_redis_connections = sum(
|
| 39 |
+
pool.get("in_use_connections", 0)
|
| 40 |
+
for pool in stats["redis_pools"].values()
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
stats["summary"] = {
|
| 44 |
+
"total_database_connections": total_db_connections,
|
| 45 |
+
"total_redis_connections": total_redis_connections,
|
| 46 |
+
"recommendation_count": len(stats["recommendations"])
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return stats
|
| 50 |
+
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(
|
| 53 |
+
"connection_pool_stats_error",
|
| 54 |
+
error=str(e),
|
| 55 |
+
exc_info=True
|
| 56 |
+
)
|
| 57 |
+
raise HTTPException(
|
| 58 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 59 |
+
detail="Failed to get connection pool statistics"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.get("/health")
|
| 64 |
+
async def check_connection_pool_health(
|
| 65 |
+
admin_user=Depends(require_admin)
|
| 66 |
+
):
|
| 67 |
+
"""
|
| 68 |
+
Check health of all connection pools.
|
| 69 |
+
|
| 70 |
+
Requires admin privileges.
|
| 71 |
+
"""
|
| 72 |
+
try:
|
| 73 |
+
health = await connection_pool_service.health_check()
|
| 74 |
+
|
| 75 |
+
logger.info(
|
| 76 |
+
"admin_connection_pool_health_check",
|
| 77 |
+
admin=admin_user.get("email"),
|
| 78 |
+
status=health["status"],
|
| 79 |
+
errors=len(health["errors"])
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
return health
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(
|
| 86 |
+
"connection_pool_health_error",
|
| 87 |
+
error=str(e),
|
| 88 |
+
exc_info=True
|
| 89 |
+
)
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 92 |
+
detail="Failed to check connection pool health"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.get("/optimize")
|
| 97 |
+
async def get_optimization_suggestions(
|
| 98 |
+
admin_user=Depends(require_admin)
|
| 99 |
+
):
|
| 100 |
+
"""
|
| 101 |
+
Get connection pool optimization suggestions.
|
| 102 |
+
|
| 103 |
+
Requires admin privileges.
|
| 104 |
+
"""
|
| 105 |
+
try:
|
| 106 |
+
optimizations = await connection_pool_service.optimize_pools()
|
| 107 |
+
|
| 108 |
+
logger.info(
|
| 109 |
+
"admin_connection_pool_optimization",
|
| 110 |
+
admin=admin_user.get("email"),
|
| 111 |
+
suggestions=len(optimizations["suggested"])
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return optimizations
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(
|
| 118 |
+
"connection_pool_optimization_error",
|
| 119 |
+
error=str(e),
|
| 120 |
+
exc_info=True
|
| 121 |
+
)
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 124 |
+
detail="Failed to get optimization suggestions"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.get("/config")
|
| 129 |
+
async def get_pool_configurations(
|
| 130 |
+
admin_user=Depends(require_admin)
|
| 131 |
+
):
|
| 132 |
+
"""
|
| 133 |
+
Get current connection pool configurations.
|
| 134 |
+
|
| 135 |
+
Requires admin privileges.
|
| 136 |
+
"""
|
| 137 |
+
try:
|
| 138 |
+
configs = {
|
| 139 |
+
"database": {
|
| 140 |
+
"main": connection_pool_service.DEFAULT_DB_POOL_CONFIG,
|
| 141 |
+
"active_pools": list(connection_pool_service._engines.keys())
|
| 142 |
+
},
|
| 143 |
+
"redis": {
|
| 144 |
+
"main": connection_pool_service.DEFAULT_REDIS_POOL_CONFIG,
|
| 145 |
+
"active_pools": list(connection_pool_service._redis_pools.keys())
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# Add pool-specific configs
|
| 150 |
+
for key, config in connection_pool_service._pool_configs.items():
|
| 151 |
+
if key.startswith("db_"):
|
| 152 |
+
pool_name = key[3:]
|
| 153 |
+
configs["database"][pool_name] = config
|
| 154 |
+
elif key.startswith("redis_"):
|
| 155 |
+
pool_name = key[6:]
|
| 156 |
+
configs["redis"][pool_name] = config
|
| 157 |
+
|
| 158 |
+
return configs
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(
|
| 162 |
+
"connection_pool_config_error",
|
| 163 |
+
error=str(e),
|
| 164 |
+
exc_info=True
|
| 165 |
+
)
|
| 166 |
+
raise HTTPException(
|
| 167 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 168 |
+
detail="Failed to get pool configurations"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@router.post("/reset-stats")
|
| 173 |
+
async def reset_pool_statistics(
|
| 174 |
+
pool_name: Optional[str] = None,
|
| 175 |
+
admin_user=Depends(require_admin)
|
| 176 |
+
):
|
| 177 |
+
"""
|
| 178 |
+
Reset connection pool statistics.
|
| 179 |
+
|
| 180 |
+
Requires admin privileges.
|
| 181 |
+
"""
|
| 182 |
+
try:
|
| 183 |
+
if pool_name:
|
| 184 |
+
# Reset specific pool stats
|
| 185 |
+
if pool_name in connection_pool_service._stats:
|
| 186 |
+
connection_pool_service._stats[pool_name] = type(
|
| 187 |
+
connection_pool_service._stats[pool_name]
|
| 188 |
+
)()
|
| 189 |
+
logger.info(
|
| 190 |
+
"admin_pool_stats_reset",
|
| 191 |
+
admin=admin_user.get("email"),
|
| 192 |
+
pool=pool_name
|
| 193 |
+
)
|
| 194 |
+
return {"status": "reset", "pool": pool_name}
|
| 195 |
+
else:
|
| 196 |
+
raise HTTPException(
|
| 197 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 198 |
+
detail=f"Pool '{pool_name}' not found"
|
| 199 |
+
)
|
| 200 |
+
else:
|
| 201 |
+
# Reset all stats
|
| 202 |
+
for key in connection_pool_service._stats:
|
| 203 |
+
connection_pool_service._stats[key] = type(
|
| 204 |
+
connection_pool_service._stats[key]
|
| 205 |
+
)()
|
| 206 |
+
|
| 207 |
+
logger.info(
|
| 208 |
+
"admin_all_pool_stats_reset",
|
| 209 |
+
admin=admin_user.get("email")
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
return {"status": "reset", "pools": list(connection_pool_service._stats.keys())}
|
| 213 |
+
|
| 214 |
+
except HTTPException:
|
| 215 |
+
raise
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(
|
| 218 |
+
"pool_stats_reset_error",
|
| 219 |
+
error=str(e),
|
| 220 |
+
exc_info=True
|
| 221 |
+
)
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 224 |
+
detail="Failed to reset statistics"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@router.get("/recommendations")
|
| 229 |
+
async def get_pool_recommendations(
|
| 230 |
+
admin_user=Depends(require_admin)
|
| 231 |
+
):
|
| 232 |
+
"""
|
| 233 |
+
Get detailed connection pool recommendations.
|
| 234 |
+
|
| 235 |
+
Requires admin privileges.
|
| 236 |
+
"""
|
| 237 |
+
try:
|
| 238 |
+
stats = await connection_pool_service.get_pool_stats()
|
| 239 |
+
recommendations = []
|
| 240 |
+
|
| 241 |
+
# Analyze database pools
|
| 242 |
+
for name, pool_stats in stats["database_pools"].items():
|
| 243 |
+
# High wait times
|
| 244 |
+
avg_wait = pool_stats.get("average_wait_time", 0)
|
| 245 |
+
if avg_wait > 0.5:
|
| 246 |
+
recommendations.append({
|
| 247 |
+
"severity": "high",
|
| 248 |
+
"pool": name,
|
| 249 |
+
"type": "database",
|
| 250 |
+
"issue": f"Average wait time is {avg_wait:.2f}s",
|
| 251 |
+
"recommendation": "Increase pool_size or max_overflow",
|
| 252 |
+
"current_config": connection_pool_service._pool_configs.get(f"db_{name}", {})
|
| 253 |
+
})
|
| 254 |
+
|
| 255 |
+
# Connection errors
|
| 256 |
+
errors = pool_stats.get("connection_errors", 0)
|
| 257 |
+
if errors > 5:
|
| 258 |
+
recommendations.append({
|
| 259 |
+
"severity": "medium",
|
| 260 |
+
"pool": name,
|
| 261 |
+
"type": "database",
|
| 262 |
+
"issue": f"{errors} connection errors detected",
|
| 263 |
+
"recommendation": "Check database health and network stability"
|
| 264 |
+
})
|
| 265 |
+
|
| 266 |
+
# Low connection reuse
|
| 267 |
+
created = pool_stats.get("connections_created", 0)
|
| 268 |
+
recycled = pool_stats.get("connections_recycled", 0)
|
| 269 |
+
if created > 0 and recycled / created < 0.5:
|
| 270 |
+
recommendations.append({
|
| 271 |
+
"severity": "low",
|
| 272 |
+
"pool": name,
|
| 273 |
+
"type": "database",
|
| 274 |
+
"issue": "Low connection reuse rate",
|
| 275 |
+
"recommendation": "Increase pool_recycle timeout"
|
| 276 |
+
})
|
| 277 |
+
|
| 278 |
+
# Analyze Redis pools
|
| 279 |
+
for name, pool_stats in stats["redis_pools"].items():
|
| 280 |
+
# Near connection limit
|
| 281 |
+
in_use = pool_stats.get("in_use_connections", 0)
|
| 282 |
+
available = pool_stats.get("available_connections", 0)
|
| 283 |
+
total = in_use + available
|
| 284 |
+
|
| 285 |
+
if total > 0 and in_use / total > 0.8:
|
| 286 |
+
recommendations.append({
|
| 287 |
+
"severity": "high",
|
| 288 |
+
"pool": name,
|
| 289 |
+
"type": "redis",
|
| 290 |
+
"issue": f"Using {in_use}/{total} connections (>80%)",
|
| 291 |
+
"recommendation": "Increase max_connections"
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
return {
|
| 295 |
+
"recommendations": recommendations,
|
| 296 |
+
"total": len(recommendations),
|
| 297 |
+
"by_severity": {
|
| 298 |
+
"high": sum(1 for r in recommendations if r["severity"] == "high"),
|
| 299 |
+
"medium": sum(1 for r in recommendations if r["severity"] == "medium"),
|
| 300 |
+
"low": sum(1 for r in recommendations if r["severity"] == "low")
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
except Exception as e:
|
| 305 |
+
logger.error(
|
| 306 |
+
"pool_recommendations_error",
|
| 307 |
+
error=str(e),
|
| 308 |
+
exc_info=True
|
| 309 |
+
)
|
| 310 |
+
raise HTTPException(
|
| 311 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 312 |
+
detail="Failed to generate recommendations"
|
| 313 |
+
)
|
src/core/config.py
CHANGED
|
@@ -262,6 +262,16 @@ class Settings(BaseSettings):
|
|
| 262 |
cache_ttl_seconds: int = Field(default=3600, description="Cache TTL")
|
| 263 |
cache_max_size: int = Field(default=1000, description="Max cache size")
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
# Feature Flags
|
| 266 |
enable_fine_tuning: bool = Field(default=False, description="Enable fine-tuning")
|
| 267 |
enable_autonomous_crawling: bool = Field(default=False, description="Enable crawling")
|
|
|
|
| 262 |
cache_ttl_seconds: int = Field(default=3600, description="Cache TTL")
|
| 263 |
cache_max_size: int = Field(default=1000, description="Max cache size")
|
| 264 |
|
| 265 |
+
# Compression
|
| 266 |
+
compression_enabled: bool = Field(default=True, description="Enable response compression")
|
| 267 |
+
compression_min_size: int = Field(default=1024, description="Min size to compress (bytes)")
|
| 268 |
+
compression_gzip_level: int = Field(default=6, description="Gzip compression level (1-9)")
|
| 269 |
+
compression_brotli_quality: int = Field(default=4, description="Brotli quality (0-11)")
|
| 270 |
+
compression_algorithms: List[str] = Field(
|
| 271 |
+
default=["gzip", "br", "deflate"],
|
| 272 |
+
description="Enabled compression algorithms"
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
# Feature Flags
|
| 276 |
enable_fine_tuning: bool = Field(default=False, description="Enable fine-tuning")
|
| 277 |
enable_autonomous_crawling: bool = Field(default=False, description="Enable crawling")
|
src/db/session.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module: db.session
|
| 3 |
+
Description: Database session management with connection pooling
|
| 4 |
+
Author: Anderson H. Silva
|
| 5 |
+
Date: 2025-01-25
|
| 6 |
+
License: Proprietary - All rights reserved
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
from typing import AsyncGenerator
|
| 11 |
+
|
| 12 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
|
| 14 |
+
from src.services.connection_pool_service import connection_pool_service
|
| 15 |
+
from src.core import get_logger
|
| 16 |
+
|
| 17 |
+
logger = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@asynccontextmanager
|
| 21 |
+
async def get_session(
|
| 22 |
+
read_only: bool = False
|
| 23 |
+
) -> AsyncGenerator[AsyncSession, None]:
|
| 24 |
+
"""
|
| 25 |
+
Get database session with connection pooling.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
read_only: Use read replica if available
|
| 29 |
+
|
| 30 |
+
Yields:
|
| 31 |
+
AsyncSession instance
|
| 32 |
+
"""
|
| 33 |
+
async with connection_pool_service.get_db_session(
|
| 34 |
+
pool_name="main",
|
| 35 |
+
read_only=read_only
|
| 36 |
+
) as session:
|
| 37 |
+
yield session
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# Alias for compatibility
|
| 41 |
+
get_db = get_session
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def init_database():
|
| 45 |
+
"""Initialize database connection pools."""
|
| 46 |
+
try:
|
| 47 |
+
await connection_pool_service.initialize()
|
| 48 |
+
logger.info("Database connection pools initialized")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(
|
| 51 |
+
"Failed to initialize database pools",
|
| 52 |
+
error=str(e),
|
| 53 |
+
exc_info=True
|
| 54 |
+
)
|
| 55 |
+
raise
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def close_database():
|
| 59 |
+
"""Close database connection pools."""
|
| 60 |
+
try:
|
| 61 |
+
await connection_pool_service.cleanup()
|
| 62 |
+
logger.info("Database connection pools closed")
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error(
|
| 65 |
+
"Failed to close database pools",
|
| 66 |
+
error=str(e),
|
| 67 |
+
exc_info=True
|
| 68 |
+
)
|
src/services/compression_service.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module: services.compression_service
|
| 3 |
+
Description: Advanced compression service with metrics and optimization
|
| 4 |
+
Author: Anderson H. Silva
|
| 5 |
+
Date: 2025-01-25
|
| 6 |
+
License: Proprietary - All rights reserved
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
from typing import Dict, Any, Optional, Tuple
|
| 11 |
+
from enum import Enum
|
| 12 |
+
import gzip
|
| 13 |
+
import zlib
|
| 14 |
+
from collections import defaultdict
|
| 15 |
+
from datetime import datetime, timedelta, timezone
|
| 16 |
+
|
| 17 |
+
from src.core import get_logger
|
| 18 |
+
from src.core.config import settings
|
| 19 |
+
|
| 20 |
+
logger = get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
import brotli
|
| 24 |
+
HAS_BROTLI = True
|
| 25 |
+
except ImportError:
|
| 26 |
+
HAS_BROTLI = False
|
| 27 |
+
brotli = None
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
import zstandard as zstd
|
| 31 |
+
HAS_ZSTD = True
|
| 32 |
+
except ImportError:
|
| 33 |
+
HAS_ZSTD = False
|
| 34 |
+
zstd = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class CompressionAlgorithm(str, Enum):
|
| 38 |
+
"""Available compression algorithms."""
|
| 39 |
+
GZIP = "gzip"
|
| 40 |
+
BROTLI = "br"
|
| 41 |
+
ZSTD = "zstd"
|
| 42 |
+
DEFLATE = "deflate"
|
| 43 |
+
IDENTITY = "identity" # No compression
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class CompressionProfile:
|
| 47 |
+
"""Compression profile for different content types."""
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
algorithm: CompressionAlgorithm,
|
| 52 |
+
level: int,
|
| 53 |
+
min_size: int = 1024,
|
| 54 |
+
max_size: Optional[int] = None
|
| 55 |
+
):
|
| 56 |
+
self.algorithm = algorithm
|
| 57 |
+
self.level = level
|
| 58 |
+
self.min_size = min_size
|
| 59 |
+
self.max_size = max_size
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class CompressionService:
|
| 63 |
+
"""Service for managing response compression."""
|
| 64 |
+
|
| 65 |
+
# Default compression profiles by content type
|
| 66 |
+
DEFAULT_PROFILES = {
|
| 67 |
+
"application/json": CompressionProfile(
|
| 68 |
+
CompressionAlgorithm.BROTLI if HAS_BROTLI else CompressionAlgorithm.GZIP,
|
| 69 |
+
level=4,
|
| 70 |
+
min_size=1024
|
| 71 |
+
),
|
| 72 |
+
"text/html": CompressionProfile(
|
| 73 |
+
CompressionAlgorithm.BROTLI if HAS_BROTLI else CompressionAlgorithm.GZIP,
|
| 74 |
+
level=6,
|
| 75 |
+
min_size=512
|
| 76 |
+
),
|
| 77 |
+
"text/plain": CompressionProfile(
|
| 78 |
+
CompressionAlgorithm.GZIP,
|
| 79 |
+
level=6,
|
| 80 |
+
min_size=1024
|
| 81 |
+
),
|
| 82 |
+
"application/javascript": CompressionProfile(
|
| 83 |
+
CompressionAlgorithm.BROTLI if HAS_BROTLI else CompressionAlgorithm.GZIP,
|
| 84 |
+
level=5,
|
| 85 |
+
min_size=512
|
| 86 |
+
),
|
| 87 |
+
"text/css": CompressionProfile(
|
| 88 |
+
CompressionAlgorithm.BROTLI if HAS_BROTLI else CompressionAlgorithm.GZIP,
|
| 89 |
+
level=6,
|
| 90 |
+
min_size=256
|
| 91 |
+
),
|
| 92 |
+
"application/xml": CompressionProfile(
|
| 93 |
+
CompressionAlgorithm.GZIP,
|
| 94 |
+
level=6,
|
| 95 |
+
min_size=1024
|
| 96 |
+
),
|
| 97 |
+
"text/csv": CompressionProfile(
|
| 98 |
+
CompressionAlgorithm.GZIP,
|
| 99 |
+
level=9, # CSVs compress very well
|
| 100 |
+
min_size=2048
|
| 101 |
+
)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
def __init__(self):
|
| 105 |
+
"""Initialize compression service."""
|
| 106 |
+
self._metrics = defaultdict(lambda: {
|
| 107 |
+
"total_bytes": 0,
|
| 108 |
+
"compressed_bytes": 0,
|
| 109 |
+
"compression_time": 0,
|
| 110 |
+
"count": 0
|
| 111 |
+
})
|
| 112 |
+
self._algorithm_stats = defaultdict(lambda: {
|
| 113 |
+
"used": 0,
|
| 114 |
+
"total_saved": 0,
|
| 115 |
+
"avg_ratio": 0
|
| 116 |
+
})
|
| 117 |
+
self._content_type_stats = defaultdict(lambda: {
|
| 118 |
+
"count": 0,
|
| 119 |
+
"avg_size": 0,
|
| 120 |
+
"avg_compressed": 0
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
def compress(
|
| 124 |
+
self,
|
| 125 |
+
data: bytes,
|
| 126 |
+
content_type: str,
|
| 127 |
+
accept_encoding: str,
|
| 128 |
+
force_algorithm: Optional[CompressionAlgorithm] = None
|
| 129 |
+
) -> Tuple[bytes, str, Dict[str, Any]]:
|
| 130 |
+
"""
|
| 131 |
+
Compress data using the best available algorithm.
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
Tuple of (compressed_data, encoding, metrics)
|
| 135 |
+
"""
|
| 136 |
+
start_time = time.time()
|
| 137 |
+
original_size = len(data)
|
| 138 |
+
|
| 139 |
+
# Get compression profile
|
| 140 |
+
profile = self._get_profile(content_type)
|
| 141 |
+
|
| 142 |
+
# Check size limits
|
| 143 |
+
if original_size < profile.min_size:
|
| 144 |
+
return data, "identity", {
|
| 145 |
+
"reason": "below_min_size",
|
| 146 |
+
"original_size": original_size,
|
| 147 |
+
"min_size": profile.min_size
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if profile.max_size and original_size > profile.max_size:
|
| 151 |
+
return data, "identity", {
|
| 152 |
+
"reason": "above_max_size",
|
| 153 |
+
"original_size": original_size,
|
| 154 |
+
"max_size": profile.max_size
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
# Choose algorithm
|
| 158 |
+
if force_algorithm:
|
| 159 |
+
algorithm = force_algorithm
|
| 160 |
+
else:
|
| 161 |
+
algorithm = self._choose_algorithm(accept_encoding, profile)
|
| 162 |
+
|
| 163 |
+
# Compress
|
| 164 |
+
try:
|
| 165 |
+
compressed_data, encoding = self._compress_with_algorithm(
|
| 166 |
+
data, algorithm, profile.level
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
compression_time = time.time() - start_time
|
| 170 |
+
compressed_size = len(compressed_data)
|
| 171 |
+
ratio = 1 - (compressed_size / original_size)
|
| 172 |
+
|
| 173 |
+
# Update metrics
|
| 174 |
+
self._update_metrics(
|
| 175 |
+
content_type,
|
| 176 |
+
algorithm,
|
| 177 |
+
original_size,
|
| 178 |
+
compressed_size,
|
| 179 |
+
compression_time
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
metrics = {
|
| 183 |
+
"algorithm": algorithm,
|
| 184 |
+
"original_size": original_size,
|
| 185 |
+
"compressed_size": compressed_size,
|
| 186 |
+
"ratio": ratio,
|
| 187 |
+
"saved_bytes": original_size - compressed_size,
|
| 188 |
+
"compression_time_ms": compression_time * 1000,
|
| 189 |
+
"throughput_mbps": (original_size / compression_time / 1024 / 1024) if compression_time > 0 else 0
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
logger.debug(
|
| 193 |
+
"compression_completed",
|
| 194 |
+
content_type=content_type,
|
| 195 |
+
algorithm=algorithm,
|
| 196 |
+
ratio=f"{ratio:.1%}",
|
| 197 |
+
time_ms=f"{compression_time * 1000:.1f}"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return compressed_data, encoding, metrics
|
| 201 |
+
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(
|
| 204 |
+
"compression_failed",
|
| 205 |
+
algorithm=algorithm,
|
| 206 |
+
error=str(e)
|
| 207 |
+
)
|
| 208 |
+
return data, "identity", {"error": str(e)}
|
| 209 |
+
|
| 210 |
+
def _get_profile(self, content_type: str) -> CompressionProfile:
|
| 211 |
+
"""Get compression profile for content type."""
|
| 212 |
+
# Extract base content type
|
| 213 |
+
base_type = content_type.split(";")[0].strip().lower()
|
| 214 |
+
|
| 215 |
+
# Check exact match
|
| 216 |
+
if base_type in self.DEFAULT_PROFILES:
|
| 217 |
+
return self.DEFAULT_PROFILES[base_type]
|
| 218 |
+
|
| 219 |
+
# Check prefix match
|
| 220 |
+
if base_type.startswith("text/"):
|
| 221 |
+
return CompressionProfile(CompressionAlgorithm.GZIP, level=6)
|
| 222 |
+
|
| 223 |
+
if base_type.startswith("application/") and "json" in base_type:
|
| 224 |
+
return CompressionProfile(CompressionAlgorithm.GZIP, level=6)
|
| 225 |
+
|
| 226 |
+
# Default profile
|
| 227 |
+
return CompressionProfile(CompressionAlgorithm.GZIP, level=5)
|
| 228 |
+
|
| 229 |
+
def _choose_algorithm(
|
| 230 |
+
self,
|
| 231 |
+
accept_encoding: str,
|
| 232 |
+
profile: CompressionProfile
|
| 233 |
+
) -> CompressionAlgorithm:
|
| 234 |
+
"""Choose best algorithm based on client support and profile."""
|
| 235 |
+
accept_encoding = accept_encoding.lower()
|
| 236 |
+
|
| 237 |
+
# Parse quality values
|
| 238 |
+
encodings = {}
|
| 239 |
+
for encoding in accept_encoding.split(","):
|
| 240 |
+
parts = encoding.strip().split(";")
|
| 241 |
+
name = parts[0].strip()
|
| 242 |
+
quality = 1.0
|
| 243 |
+
|
| 244 |
+
if len(parts) > 1:
|
| 245 |
+
for param in parts[1:]:
|
| 246 |
+
if param.strip().startswith("q="):
|
| 247 |
+
try:
|
| 248 |
+
quality = float(param.split("=")[1])
|
| 249 |
+
except:
|
| 250 |
+
pass
|
| 251 |
+
|
| 252 |
+
encodings[name] = quality
|
| 253 |
+
|
| 254 |
+
# Prefer profile algorithm if supported
|
| 255 |
+
if profile.algorithm == CompressionAlgorithm.BROTLI and "br" in encodings:
|
| 256 |
+
return CompressionAlgorithm.BROTLI
|
| 257 |
+
|
| 258 |
+
if profile.algorithm == CompressionAlgorithm.ZSTD and "zstd" in encodings and HAS_ZSTD:
|
| 259 |
+
return CompressionAlgorithm.ZSTD
|
| 260 |
+
|
| 261 |
+
# Check alternatives in order of preference
|
| 262 |
+
if "br" in encodings and HAS_BROTLI and encodings.get("br", 0) > 0:
|
| 263 |
+
return CompressionAlgorithm.BROTLI
|
| 264 |
+
|
| 265 |
+
if "zstd" in encodings and HAS_ZSTD and encodings.get("zstd", 0) > 0:
|
| 266 |
+
return CompressionAlgorithm.ZSTD
|
| 267 |
+
|
| 268 |
+
if "gzip" in encodings and encodings.get("gzip", 0) > 0:
|
| 269 |
+
return CompressionAlgorithm.GZIP
|
| 270 |
+
|
| 271 |
+
if "deflate" in encodings and encodings.get("deflate", 0) > 0:
|
| 272 |
+
return CompressionAlgorithm.DEFLATE
|
| 273 |
+
|
| 274 |
+
# Default to gzip if nothing else
|
| 275 |
+
return CompressionAlgorithm.GZIP
|
| 276 |
+
|
| 277 |
+
def _compress_with_algorithm(
|
| 278 |
+
self,
|
| 279 |
+
data: bytes,
|
| 280 |
+
algorithm: CompressionAlgorithm,
|
| 281 |
+
level: int
|
| 282 |
+
) -> Tuple[bytes, str]:
|
| 283 |
+
"""Compress data with specified algorithm."""
|
| 284 |
+
if algorithm == CompressionAlgorithm.GZIP:
|
| 285 |
+
return gzip.compress(data, compresslevel=level), "gzip"
|
| 286 |
+
|
| 287 |
+
elif algorithm == CompressionAlgorithm.BROTLI:
|
| 288 |
+
if not HAS_BROTLI:
|
| 289 |
+
raise RuntimeError("Brotli not available")
|
| 290 |
+
return brotli.compress(data, quality=level), "br"
|
| 291 |
+
|
| 292 |
+
elif algorithm == CompressionAlgorithm.ZSTD:
|
| 293 |
+
if not HAS_ZSTD:
|
| 294 |
+
raise RuntimeError("Zstandard not available")
|
| 295 |
+
cctx = zstd.ZstdCompressor(level=level)
|
| 296 |
+
return cctx.compress(data), "zstd"
|
| 297 |
+
|
| 298 |
+
elif algorithm == CompressionAlgorithm.DEFLATE:
|
| 299 |
+
return zlib.compress(data, level=level), "deflate"
|
| 300 |
+
|
| 301 |
+
else:
|
| 302 |
+
return data, "identity"
|
| 303 |
+
|
| 304 |
+
def _update_metrics(
|
| 305 |
+
self,
|
| 306 |
+
content_type: str,
|
| 307 |
+
algorithm: CompressionAlgorithm,
|
| 308 |
+
original_size: int,
|
| 309 |
+
compressed_size: int,
|
| 310 |
+
compression_time: float
|
| 311 |
+
):
|
| 312 |
+
"""Update compression metrics."""
|
| 313 |
+
# Overall metrics
|
| 314 |
+
metrics = self._metrics["overall"]
|
| 315 |
+
metrics["total_bytes"] += original_size
|
| 316 |
+
metrics["compressed_bytes"] += compressed_size
|
| 317 |
+
metrics["compression_time"] += compression_time
|
| 318 |
+
metrics["count"] += 1
|
| 319 |
+
|
| 320 |
+
# Per content type metrics
|
| 321 |
+
ct_metrics = self._metrics[content_type]
|
| 322 |
+
ct_metrics["total_bytes"] += original_size
|
| 323 |
+
ct_metrics["compressed_bytes"] += compressed_size
|
| 324 |
+
ct_metrics["compression_time"] += compression_time
|
| 325 |
+
ct_metrics["count"] += 1
|
| 326 |
+
|
| 327 |
+
# Algorithm statistics
|
| 328 |
+
algo_stats = self._algorithm_stats[algorithm]
|
| 329 |
+
algo_stats["used"] += 1
|
| 330 |
+
algo_stats["total_saved"] += (original_size - compressed_size)
|
| 331 |
+
|
| 332 |
+
# Content type statistics
|
| 333 |
+
ct_stats = self._content_type_stats[content_type]
|
| 334 |
+
ct_stats["count"] += 1
|
| 335 |
+
ct_stats["avg_size"] = (
|
| 336 |
+
(ct_stats["avg_size"] * (ct_stats["count"] - 1) + original_size) /
|
| 337 |
+
ct_stats["count"]
|
| 338 |
+
)
|
| 339 |
+
ct_stats["avg_compressed"] = (
|
| 340 |
+
(ct_stats["avg_compressed"] * (ct_stats["count"] - 1) + compressed_size) /
|
| 341 |
+
ct_stats["count"]
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
def get_metrics(self) -> Dict[str, Any]:
|
| 345 |
+
"""Get compression metrics."""
|
| 346 |
+
overall = self._metrics["overall"]
|
| 347 |
+
|
| 348 |
+
if overall["count"] == 0:
|
| 349 |
+
return {
|
| 350 |
+
"enabled": True,
|
| 351 |
+
"algorithms_available": self._get_available_algorithms(),
|
| 352 |
+
"total_requests": 0
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
total_saved = overall["total_bytes"] - overall["compressed_bytes"]
|
| 356 |
+
avg_ratio = total_saved / overall["total_bytes"] if overall["total_bytes"] > 0 else 0
|
| 357 |
+
|
| 358 |
+
return {
|
| 359 |
+
"enabled": True,
|
| 360 |
+
"algorithms_available": self._get_available_algorithms(),
|
| 361 |
+
"total_requests": overall["count"],
|
| 362 |
+
"total_bytes_original": overall["total_bytes"],
|
| 363 |
+
"total_bytes_compressed": overall["compressed_bytes"],
|
| 364 |
+
"total_bytes_saved": total_saved,
|
| 365 |
+
"average_compression_ratio": avg_ratio,
|
| 366 |
+
"average_compression_time_ms": (overall["compression_time"] / overall["count"] * 1000) if overall["count"] > 0 else 0,
|
| 367 |
+
"content_types": self._get_content_type_metrics(),
|
| 368 |
+
"algorithms": self._get_algorithm_metrics()
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
def _get_available_algorithms(self) -> List[str]:
|
| 372 |
+
"""Get list of available compression algorithms."""
|
| 373 |
+
algorithms = ["gzip", "deflate"]
|
| 374 |
+
if HAS_BROTLI:
|
| 375 |
+
algorithms.append("br")
|
| 376 |
+
if HAS_ZSTD:
|
| 377 |
+
algorithms.append("zstd")
|
| 378 |
+
return algorithms
|
| 379 |
+
|
| 380 |
+
def _get_content_type_metrics(self) -> Dict[str, Any]:
|
| 381 |
+
"""Get metrics grouped by content type."""
|
| 382 |
+
result = {}
|
| 383 |
+
|
| 384 |
+
for content_type, metrics in self._metrics.items():
|
| 385 |
+
if content_type == "overall" or metrics["count"] == 0:
|
| 386 |
+
continue
|
| 387 |
+
|
| 388 |
+
saved = metrics["total_bytes"] - metrics["compressed_bytes"]
|
| 389 |
+
ratio = saved / metrics["total_bytes"] if metrics["total_bytes"] > 0 else 0
|
| 390 |
+
|
| 391 |
+
result[content_type] = {
|
| 392 |
+
"requests": metrics["count"],
|
| 393 |
+
"total_size": metrics["total_bytes"],
|
| 394 |
+
"compressed_size": metrics["compressed_bytes"],
|
| 395 |
+
"saved_bytes": saved,
|
| 396 |
+
"compression_ratio": ratio,
|
| 397 |
+
"avg_time_ms": (metrics["compression_time"] / metrics["count"] * 1000)
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
return result
|
| 401 |
+
|
| 402 |
+
def _get_algorithm_metrics(self) -> Dict[str, Any]:
|
| 403 |
+
"""Get metrics grouped by algorithm."""
|
| 404 |
+
result = {}
|
| 405 |
+
|
| 406 |
+
for algorithm, stats in self._algorithm_stats.items():
|
| 407 |
+
if stats["used"] == 0:
|
| 408 |
+
continue
|
| 409 |
+
|
| 410 |
+
result[algorithm] = {
|
| 411 |
+
"times_used": stats["used"],
|
| 412 |
+
"total_bytes_saved": stats["total_saved"],
|
| 413 |
+
"avg_bytes_saved": stats["total_saved"] / stats["used"]
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
return result
|
| 417 |
+
|
| 418 |
+
def optimize_settings(self) -> Dict[str, Any]:
|
| 419 |
+
"""Analyze metrics and suggest optimizations."""
|
| 420 |
+
suggestions = []
|
| 421 |
+
|
| 422 |
+
# Check if Brotli should be enabled
|
| 423 |
+
if not HAS_BROTLI:
|
| 424 |
+
suggestions.append({
|
| 425 |
+
"type": "install_brotli",
|
| 426 |
+
"reason": "Brotli provides better compression ratios",
|
| 427 |
+
"command": "pip install brotli"
|
| 428 |
+
})
|
| 429 |
+
|
| 430 |
+
# Check compression ratios by content type
|
| 431 |
+
for content_type, stats in self._content_type_stats.items():
|
| 432 |
+
if stats["count"] < 10:
|
| 433 |
+
continue
|
| 434 |
+
|
| 435 |
+
avg_ratio = 1 - (stats["avg_compressed"] / stats["avg_size"]) if stats["avg_size"] > 0 else 0
|
| 436 |
+
|
| 437 |
+
if avg_ratio < 0.2:
|
| 438 |
+
suggestions.append({
|
| 439 |
+
"type": "adjust_min_size",
|
| 440 |
+
"content_type": content_type,
|
| 441 |
+
"reason": f"Low compression ratio ({avg_ratio:.1%})",
|
| 442 |
+
"current_avg_size": stats["avg_size"],
|
| 443 |
+
"suggestion": "Consider increasing minimum size threshold"
|
| 444 |
+
})
|
| 445 |
+
|
| 446 |
+
# Check algorithm usage
|
| 447 |
+
gzip_stats = self._algorithm_stats.get(CompressionAlgorithm.GZIP, {"used": 0})
|
| 448 |
+
brotli_stats = self._algorithm_stats.get(CompressionAlgorithm.BROTLI, {"used": 0})
|
| 449 |
+
|
| 450 |
+
if HAS_BROTLI and brotli_stats["used"] < gzip_stats["used"] * 0.1:
|
| 451 |
+
suggestions.append({
|
| 452 |
+
"type": "promote_brotli",
|
| 453 |
+
"reason": "Brotli underutilized despite being available",
|
| 454 |
+
"suggestion": "Check client Accept-Encoding headers"
|
| 455 |
+
})
|
| 456 |
+
|
| 457 |
+
return {
|
| 458 |
+
"suggestions": suggestions,
|
| 459 |
+
"optimal_settings": self._calculate_optimal_settings()
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
def _calculate_optimal_settings(self) -> Dict[str, Any]:
|
| 463 |
+
"""Calculate optimal compression settings based on metrics."""
|
| 464 |
+
settings = {}
|
| 465 |
+
|
| 466 |
+
# Recommend levels based on average compression time
|
| 467 |
+
overall = self._metrics["overall"]
|
| 468 |
+
if overall["count"] > 0:
|
| 469 |
+
avg_time = overall["compression_time"] / overall["count"]
|
| 470 |
+
|
| 471 |
+
if avg_time < 0.001: # < 1ms
|
| 472 |
+
settings["recommended_gzip_level"] = 9
|
| 473 |
+
settings["recommended_brotli_quality"] = 6
|
| 474 |
+
elif avg_time < 0.005: # < 5ms
|
| 475 |
+
settings["recommended_gzip_level"] = 6
|
| 476 |
+
settings["recommended_brotli_quality"] = 4
|
| 477 |
+
else:
|
| 478 |
+
settings["recommended_gzip_level"] = 4
|
| 479 |
+
settings["recommended_brotli_quality"] = 2
|
| 480 |
+
|
| 481 |
+
return settings
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
# Global instance
|
| 485 |
+
compression_service = CompressionService()
|
src/services/connection_pool_service.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module: services.connection_pool_service
|
| 3 |
+
Description: Advanced connection pooling management
|
| 4 |
+
Author: Anderson H. Silva
|
| 5 |
+
Date: 2025-01-25
|
| 6 |
+
License: Proprietary - All rights reserved
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
from typing import Dict, Any, Optional, List
|
| 11 |
+
from datetime import datetime, timedelta, timezone
|
| 12 |
+
from contextlib import asynccontextmanager
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, AsyncEngine
|
| 16 |
+
from sqlalchemy.pool import NullPool, QueuePool, StaticPool
|
| 17 |
+
from sqlalchemy.orm import sessionmaker
|
| 18 |
+
from sqlalchemy import text, event
|
| 19 |
+
import redis.asyncio as redis
|
| 20 |
+
|
| 21 |
+
from src.core import get_logger
|
| 22 |
+
from src.core.config import settings
|
| 23 |
+
|
| 24 |
+
logger = get_logger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ConnectionStats:
|
| 28 |
+
"""Track connection pool statistics."""
|
| 29 |
+
|
| 30 |
+
def __init__(self):
|
| 31 |
+
self.connections_created = 0
|
| 32 |
+
self.connections_closed = 0
|
| 33 |
+
self.connections_recycled = 0
|
| 34 |
+
self.connection_errors = 0
|
| 35 |
+
self.wait_time_total = 0.0
|
| 36 |
+
self.wait_count = 0
|
| 37 |
+
self.active_connections = 0
|
| 38 |
+
self.peak_connections = 0
|
| 39 |
+
self.last_reset = datetime.now(timezone.utc)
|
| 40 |
+
|
| 41 |
+
def record_connection_created(self):
|
| 42 |
+
"""Record new connection creation."""
|
| 43 |
+
self.connections_created += 1
|
| 44 |
+
self.active_connections += 1
|
| 45 |
+
if self.active_connections > self.peak_connections:
|
| 46 |
+
self.peak_connections = self.active_connections
|
| 47 |
+
|
| 48 |
+
def record_connection_closed(self):
|
| 49 |
+
"""Record connection closure."""
|
| 50 |
+
self.connections_closed += 1
|
| 51 |
+
self.active_connections = max(0, self.active_connections - 1)
|
| 52 |
+
|
| 53 |
+
def record_wait(self, wait_time: float):
|
| 54 |
+
"""Record connection wait time."""
|
| 55 |
+
self.wait_time_total += wait_time
|
| 56 |
+
self.wait_count += 1
|
| 57 |
+
|
| 58 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 59 |
+
"""Get current statistics."""
|
| 60 |
+
uptime = (datetime.now(timezone.utc) - self.last_reset).total_seconds()
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"connections_created": self.connections_created,
|
| 64 |
+
"connections_closed": self.connections_closed,
|
| 65 |
+
"connections_recycled": self.connections_recycled,
|
| 66 |
+
"connection_errors": self.connection_errors,
|
| 67 |
+
"active_connections": self.active_connections,
|
| 68 |
+
"peak_connections": self.peak_connections,
|
| 69 |
+
"average_wait_time": self.wait_time_total / max(self.wait_count, 1),
|
| 70 |
+
"total_waits": self.wait_count,
|
| 71 |
+
"uptime_seconds": uptime,
|
| 72 |
+
"connections_per_second": self.connections_created / max(uptime, 1)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class ConnectionPoolService:
|
| 77 |
+
"""Advanced connection pool management service."""
|
| 78 |
+
|
| 79 |
+
def __init__(self):
|
| 80 |
+
"""Initialize connection pool service."""
|
| 81 |
+
self._engines: Dict[str, AsyncEngine] = {}
|
| 82 |
+
self._redis_pools: Dict[str, redis.ConnectionPool] = {}
|
| 83 |
+
self._stats: Dict[str, ConnectionStats] = {}
|
| 84 |
+
self._pool_configs: Dict[str, Dict[str, Any]] = {}
|
| 85 |
+
|
| 86 |
+
# Default pool configurations
|
| 87 |
+
self.DEFAULT_DB_POOL_CONFIG = {
|
| 88 |
+
"pool_size": settings.database_pool_size,
|
| 89 |
+
"max_overflow": settings.database_pool_overflow,
|
| 90 |
+
"pool_timeout": settings.database_pool_timeout,
|
| 91 |
+
"pool_recycle": 3600, # Recycle connections after 1 hour
|
| 92 |
+
"pool_pre_ping": True, # Test connections before use
|
| 93 |
+
"echo_pool": settings.debug,
|
| 94 |
+
"pool_use_lifo": True, # Last In First Out for better cache locality
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
self.DEFAULT_REDIS_POOL_CONFIG = {
|
| 98 |
+
"max_connections": settings.redis_pool_size,
|
| 99 |
+
"decode_responses": True,
|
| 100 |
+
"socket_keepalive": True,
|
| 101 |
+
"socket_keepalive_options": {
|
| 102 |
+
1: 1, # TCP_KEEPIDLE
|
| 103 |
+
2: 1, # TCP_KEEPINTVL
|
| 104 |
+
3: 5, # TCP_KEEPCNT
|
| 105 |
+
},
|
| 106 |
+
"retry_on_timeout": True,
|
| 107 |
+
"health_check_interval": 30
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async def initialize(self):
|
| 111 |
+
"""Initialize connection pools."""
|
| 112 |
+
try:
|
| 113 |
+
# Initialize main database pool
|
| 114 |
+
await self.create_db_pool(
|
| 115 |
+
"main",
|
| 116 |
+
settings.get_database_url(async_mode=True),
|
| 117 |
+
self.DEFAULT_DB_POOL_CONFIG
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Initialize read replica pool if configured
|
| 121 |
+
if hasattr(settings, "database_read_url"):
|
| 122 |
+
read_config = self.DEFAULT_DB_POOL_CONFIG.copy()
|
| 123 |
+
read_config["pool_size"] = settings.database_pool_size * 2 # More connections for reads
|
| 124 |
+
|
| 125 |
+
await self.create_db_pool(
|
| 126 |
+
"read",
|
| 127 |
+
settings.database_read_url,
|
| 128 |
+
read_config
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Initialize Redis pools
|
| 132 |
+
await self.create_redis_pool(
|
| 133 |
+
"main",
|
| 134 |
+
settings.redis_url,
|
| 135 |
+
self.DEFAULT_REDIS_POOL_CONFIG
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# Initialize cache Redis pool with different settings
|
| 139 |
+
cache_config = self.DEFAULT_REDIS_POOL_CONFIG.copy()
|
| 140 |
+
cache_config["max_connections"] = settings.redis_pool_size * 2
|
| 141 |
+
|
| 142 |
+
await self.create_redis_pool(
|
| 143 |
+
"cache",
|
| 144 |
+
settings.redis_url,
|
| 145 |
+
cache_config
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
logger.info("connection_pools_initialized")
|
| 149 |
+
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(
|
| 152 |
+
"connection_pool_initialization_failed",
|
| 153 |
+
error=str(e),
|
| 154 |
+
exc_info=True
|
| 155 |
+
)
|
| 156 |
+
raise
|
| 157 |
+
|
| 158 |
+
async def create_db_pool(
|
| 159 |
+
self,
|
| 160 |
+
name: str,
|
| 161 |
+
url: str,
|
| 162 |
+
config: Dict[str, Any]
|
| 163 |
+
) -> AsyncEngine:
|
| 164 |
+
"""Create database connection pool."""
|
| 165 |
+
try:
|
| 166 |
+
# Create engine with custom pool
|
| 167 |
+
engine = create_async_engine(
|
| 168 |
+
url,
|
| 169 |
+
poolclass=QueuePool,
|
| 170 |
+
**config
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Initialize stats
|
| 174 |
+
self._stats[f"db_{name}"] = ConnectionStats()
|
| 175 |
+
stats = self._stats[f"db_{name}"]
|
| 176 |
+
|
| 177 |
+
# Add event listeners for monitoring
|
| 178 |
+
@event.listens_for(engine.sync_engine, "connect")
|
| 179 |
+
def on_connect(dbapi_conn, connection_record):
|
| 180 |
+
stats.record_connection_created()
|
| 181 |
+
connection_record.info['connected_at'] = time.time()
|
| 182 |
+
logger.debug(f"Database connection created for pool '{name}'")
|
| 183 |
+
|
| 184 |
+
@event.listens_for(engine.sync_engine, "close")
|
| 185 |
+
def on_close(dbapi_conn, connection_record):
|
| 186 |
+
stats.record_connection_closed()
|
| 187 |
+
if 'connected_at' in connection_record.info:
|
| 188 |
+
lifetime = time.time() - connection_record.info['connected_at']
|
| 189 |
+
logger.debug(f"Database connection closed for pool '{name}', lifetime: {lifetime:.1f}s")
|
| 190 |
+
|
| 191 |
+
@event.listens_for(engine.sync_engine, "checkout")
|
| 192 |
+
def on_checkout(dbapi_conn, connection_record, connection_proxy):
|
| 193 |
+
connection_record.info['checkout_at'] = time.time()
|
| 194 |
+
|
| 195 |
+
@event.listens_for(engine.sync_engine, "checkin")
|
| 196 |
+
def on_checkin(dbapi_conn, connection_record):
|
| 197 |
+
if 'checkout_at' in connection_record.info:
|
| 198 |
+
usage_time = time.time() - connection_record.info['checkout_at']
|
| 199 |
+
if usage_time > 1.0: # Log slow connection usage
|
| 200 |
+
logger.warning(f"Slow connection usage in pool '{name}': {usage_time:.2f}s")
|
| 201 |
+
|
| 202 |
+
# Store engine and config
|
| 203 |
+
self._engines[name] = engine
|
| 204 |
+
self._pool_configs[f"db_{name}"] = config
|
| 205 |
+
|
| 206 |
+
# Test connection
|
| 207 |
+
async with engine.connect() as conn:
|
| 208 |
+
await conn.execute(text("SELECT 1"))
|
| 209 |
+
|
| 210 |
+
logger.info(
|
| 211 |
+
f"database_pool_created",
|
| 212 |
+
pool=name,
|
| 213 |
+
size=config["pool_size"],
|
| 214 |
+
max_overflow=config["max_overflow"]
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
return engine
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
logger.error(
|
| 221 |
+
f"database_pool_creation_failed",
|
| 222 |
+
pool=name,
|
| 223 |
+
error=str(e)
|
| 224 |
+
)
|
| 225 |
+
raise
|
| 226 |
+
|
| 227 |
+
async def create_redis_pool(
|
| 228 |
+
self,
|
| 229 |
+
name: str,
|
| 230 |
+
url: str,
|
| 231 |
+
config: Dict[str, Any]
|
| 232 |
+
) -> redis.ConnectionPool:
|
| 233 |
+
"""Create Redis connection pool."""
|
| 234 |
+
try:
|
| 235 |
+
# Parse password from URL if present
|
| 236 |
+
if settings.redis_password:
|
| 237 |
+
config["password"] = settings.redis_password.get_secret_value()
|
| 238 |
+
|
| 239 |
+
# Create connection pool
|
| 240 |
+
pool = redis.ConnectionPool.from_url(
|
| 241 |
+
url,
|
| 242 |
+
**config
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Initialize stats
|
| 246 |
+
self._stats[f"redis_{name}"] = ConnectionStats()
|
| 247 |
+
|
| 248 |
+
# Store pool and config
|
| 249 |
+
self._redis_pools[name] = pool
|
| 250 |
+
self._pool_configs[f"redis_{name}"] = config
|
| 251 |
+
|
| 252 |
+
# Test connection
|
| 253 |
+
client = redis.Redis(connection_pool=pool)
|
| 254 |
+
await client.ping()
|
| 255 |
+
await client.aclose()
|
| 256 |
+
|
| 257 |
+
logger.info(
|
| 258 |
+
"redis_pool_created",
|
| 259 |
+
pool=name,
|
| 260 |
+
max_connections=config["max_connections"]
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
return pool
|
| 264 |
+
|
| 265 |
+
except Exception as e:
|
| 266 |
+
logger.error(
|
| 267 |
+
"redis_pool_creation_failed",
|
| 268 |
+
pool=name,
|
| 269 |
+
error=str(e)
|
| 270 |
+
)
|
| 271 |
+
raise
|
| 272 |
+
|
| 273 |
+
@asynccontextmanager
|
| 274 |
+
async def get_db_session(
|
| 275 |
+
self,
|
| 276 |
+
pool_name: str = "main",
|
| 277 |
+
read_only: bool = False
|
| 278 |
+
):
|
| 279 |
+
"""Get database session from pool."""
|
| 280 |
+
# Use read pool if available and requested
|
| 281 |
+
if read_only and "read" in self._engines:
|
| 282 |
+
pool_name = "read"
|
| 283 |
+
|
| 284 |
+
engine = self._engines.get(pool_name)
|
| 285 |
+
if not engine:
|
| 286 |
+
raise ValueError(f"Database pool '{pool_name}' not found")
|
| 287 |
+
|
| 288 |
+
# Track wait time
|
| 289 |
+
start_time = time.time()
|
| 290 |
+
|
| 291 |
+
async_session = sessionmaker(
|
| 292 |
+
engine,
|
| 293 |
+
class_=AsyncSession,
|
| 294 |
+
expire_on_commit=False
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
async with async_session() as session:
|
| 298 |
+
wait_time = time.time() - start_time
|
| 299 |
+
if wait_time > 0.1:
|
| 300 |
+
self._stats[f"db_{pool_name}"].record_wait(wait_time)
|
| 301 |
+
|
| 302 |
+
try:
|
| 303 |
+
yield session
|
| 304 |
+
await session.commit()
|
| 305 |
+
except Exception:
|
| 306 |
+
await session.rollback()
|
| 307 |
+
raise
|
| 308 |
+
finally:
|
| 309 |
+
await session.close()
|
| 310 |
+
|
| 311 |
+
async def get_redis_client(
|
| 312 |
+
self,
|
| 313 |
+
pool_name: str = "main"
|
| 314 |
+
) -> redis.Redis:
|
| 315 |
+
"""Get Redis client from pool."""
|
| 316 |
+
pool = self._redis_pools.get(pool_name)
|
| 317 |
+
if not pool:
|
| 318 |
+
raise ValueError(f"Redis pool '{pool_name}' not found")
|
| 319 |
+
|
| 320 |
+
return redis.Redis(connection_pool=pool)
|
| 321 |
+
|
| 322 |
+
async def get_pool_stats(self) -> Dict[str, Any]:
|
| 323 |
+
"""Get statistics for all connection pools."""
|
| 324 |
+
stats = {
|
| 325 |
+
"database_pools": {},
|
| 326 |
+
"redis_pools": {},
|
| 327 |
+
"recommendations": []
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
# Database pool stats
|
| 331 |
+
for name, engine in self._engines.items():
|
| 332 |
+
pool = engine.pool
|
| 333 |
+
pool_stats = self._stats.get(f"db_{name}")
|
| 334 |
+
|
| 335 |
+
if pool_stats:
|
| 336 |
+
db_stats = pool_stats.get_stats()
|
| 337 |
+
|
| 338 |
+
# Add pool-specific stats
|
| 339 |
+
if hasattr(pool, 'size'):
|
| 340 |
+
db_stats["pool_size"] = pool.size()
|
| 341 |
+
if hasattr(pool, 'checked_out'):
|
| 342 |
+
db_stats["checked_out"] = pool.checked_out()
|
| 343 |
+
if hasattr(pool, 'overflow'):
|
| 344 |
+
db_stats["overflow"] = pool.overflow()
|
| 345 |
+
|
| 346 |
+
stats["database_pools"][name] = db_stats
|
| 347 |
+
|
| 348 |
+
# Generate recommendations
|
| 349 |
+
if db_stats.get("average_wait_time", 0) > 0.5:
|
| 350 |
+
stats["recommendations"].append({
|
| 351 |
+
"pool": f"db_{name}",
|
| 352 |
+
"issue": "High wait times",
|
| 353 |
+
"suggestion": "Increase pool_size or max_overflow"
|
| 354 |
+
})
|
| 355 |
+
|
| 356 |
+
if db_stats.get("connection_errors", 0) > 10:
|
| 357 |
+
stats["recommendations"].append({
|
| 358 |
+
"pool": f"db_{name}",
|
| 359 |
+
"issue": "High error rate",
|
| 360 |
+
"suggestion": "Check database health and network stability"
|
| 361 |
+
})
|
| 362 |
+
|
| 363 |
+
# Redis pool stats
|
| 364 |
+
for name, pool in self._redis_pools.items():
|
| 365 |
+
pool_stats = self._stats.get(f"redis_{name}")
|
| 366 |
+
|
| 367 |
+
if pool_stats:
|
| 368 |
+
redis_stats = pool_stats.get_stats()
|
| 369 |
+
|
| 370 |
+
# Add Redis-specific stats
|
| 371 |
+
redis_stats["created_connections"] = pool.created_connections
|
| 372 |
+
redis_stats["available_connections"] = len(pool._available_connections)
|
| 373 |
+
redis_stats["in_use_connections"] = len(pool._in_use_connections)
|
| 374 |
+
|
| 375 |
+
stats["redis_pools"][name] = redis_stats
|
| 376 |
+
|
| 377 |
+
# Recommendations
|
| 378 |
+
if redis_stats["in_use_connections"] > pool.max_connections * 0.8:
|
| 379 |
+
stats["recommendations"].append({
|
| 380 |
+
"pool": f"redis_{name}",
|
| 381 |
+
"issue": "Near connection limit",
|
| 382 |
+
"suggestion": "Increase max_connections"
|
| 383 |
+
})
|
| 384 |
+
|
| 385 |
+
return stats
|
| 386 |
+
|
| 387 |
+
async def optimize_pools(self) -> Dict[str, Any]:
|
| 388 |
+
"""Analyze and optimize connection pools."""
|
| 389 |
+
optimizations = {
|
| 390 |
+
"performed": [],
|
| 391 |
+
"suggested": []
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
# Check database pools
|
| 395 |
+
for name, engine in self._engines.items():
|
| 396 |
+
pool = engine.pool
|
| 397 |
+
stats = self._stats.get(f"db_{name}")
|
| 398 |
+
|
| 399 |
+
if stats:
|
| 400 |
+
# Auto-adjust pool size based on usage
|
| 401 |
+
current_config = self._pool_configs.get(f"db_{name}", {})
|
| 402 |
+
current_size = current_config.get("pool_size", 10)
|
| 403 |
+
|
| 404 |
+
if stats.peak_connections > current_size * 0.9:
|
| 405 |
+
suggested_size = min(current_size * 2, 50)
|
| 406 |
+
optimizations["suggested"].append({
|
| 407 |
+
"pool": f"db_{name}",
|
| 408 |
+
"action": "increase_pool_size",
|
| 409 |
+
"current": current_size,
|
| 410 |
+
"suggested": suggested_size,
|
| 411 |
+
"reason": f"Peak usage ({stats.peak_connections}) near limit"
|
| 412 |
+
})
|
| 413 |
+
|
| 414 |
+
# Check for idle connections
|
| 415 |
+
if hasattr(pool, 'size') and hasattr(pool, 'checked_out'):
|
| 416 |
+
idle_ratio = 1 - (pool.checked_out() / max(pool.size(), 1))
|
| 417 |
+
if idle_ratio > 0.7 and current_size > 5:
|
| 418 |
+
suggested_size = max(5, current_size // 2)
|
| 419 |
+
optimizations["suggested"].append({
|
| 420 |
+
"pool": f"db_{name}",
|
| 421 |
+
"action": "decrease_pool_size",
|
| 422 |
+
"current": current_size,
|
| 423 |
+
"suggested": suggested_size,
|
| 424 |
+
"reason": f"High idle ratio ({idle_ratio:.1%})"
|
| 425 |
+
})
|
| 426 |
+
|
| 427 |
+
# Check Redis pools
|
| 428 |
+
for name, pool in self._redis_pools.items():
|
| 429 |
+
stats = self._stats.get(f"redis_{name}")
|
| 430 |
+
|
| 431 |
+
if stats:
|
| 432 |
+
current_max = pool.max_connections
|
| 433 |
+
|
| 434 |
+
if stats.peak_connections > current_max * 0.8:
|
| 435 |
+
suggested_max = min(current_max * 2, 100)
|
| 436 |
+
optimizations["suggested"].append({
|
| 437 |
+
"pool": f"redis_{name}",
|
| 438 |
+
"action": "increase_max_connections",
|
| 439 |
+
"current": current_max,
|
| 440 |
+
"suggested": suggested_max,
|
| 441 |
+
"reason": f"Peak usage ({stats.peak_connections}) near limit"
|
| 442 |
+
})
|
| 443 |
+
|
| 444 |
+
return optimizations
|
| 445 |
+
|
| 446 |
+
async def health_check(self) -> Dict[str, Any]:
|
| 447 |
+
"""Perform health check on all pools."""
|
| 448 |
+
health = {
|
| 449 |
+
"status": "healthy",
|
| 450 |
+
"pools": {},
|
| 451 |
+
"errors": []
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
# Check database pools
|
| 455 |
+
for name, engine in self._engines.items():
|
| 456 |
+
try:
|
| 457 |
+
async with engine.connect() as conn:
|
| 458 |
+
result = await conn.execute(text("SELECT 1"))
|
| 459 |
+
health["pools"][f"db_{name}"] = {
|
| 460 |
+
"status": "healthy",
|
| 461 |
+
"response_time_ms": 0 # Would need to measure
|
| 462 |
+
}
|
| 463 |
+
except Exception as e:
|
| 464 |
+
health["status"] = "unhealthy"
|
| 465 |
+
health["pools"][f"db_{name}"] = {
|
| 466 |
+
"status": "unhealthy",
|
| 467 |
+
"error": str(e)
|
| 468 |
+
}
|
| 469 |
+
health["errors"].append(f"Database pool '{name}': {str(e)}")
|
| 470 |
+
|
| 471 |
+
# Check Redis pools
|
| 472 |
+
for name, pool in self._redis_pools.items():
|
| 473 |
+
try:
|
| 474 |
+
client = redis.Redis(connection_pool=pool)
|
| 475 |
+
start = time.time()
|
| 476 |
+
await client.ping()
|
| 477 |
+
response_time = (time.time() - start) * 1000
|
| 478 |
+
|
| 479 |
+
health["pools"][f"redis_{name}"] = {
|
| 480 |
+
"status": "healthy",
|
| 481 |
+
"response_time_ms": round(response_time, 2)
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
await client.aclose()
|
| 485 |
+
except Exception as e:
|
| 486 |
+
health["status"] = "unhealthy"
|
| 487 |
+
health["pools"][f"redis_{name}"] = {
|
| 488 |
+
"status": "unhealthy",
|
| 489 |
+
"error": str(e)
|
| 490 |
+
}
|
| 491 |
+
health["errors"].append(f"Redis pool '{name}': {str(e)}")
|
| 492 |
+
|
| 493 |
+
return health
|
| 494 |
+
|
| 495 |
+
async def cleanup(self):
|
| 496 |
+
"""Clean up all connection pools."""
|
| 497 |
+
# Close database engines
|
| 498 |
+
for name, engine in self._engines.items():
|
| 499 |
+
try:
|
| 500 |
+
await engine.dispose()
|
| 501 |
+
logger.info(f"Database pool '{name}' closed")
|
| 502 |
+
except Exception as e:
|
| 503 |
+
logger.error(f"Error closing database pool '{name}': {e}")
|
| 504 |
+
|
| 505 |
+
# Close Redis pools
|
| 506 |
+
for name, pool in self._redis_pools.items():
|
| 507 |
+
try:
|
| 508 |
+
await pool.disconnect()
|
| 509 |
+
logger.info(f"Redis pool '{name}' closed")
|
| 510 |
+
except Exception as e:
|
| 511 |
+
logger.error(f"Error closing Redis pool '{name}': {e}")
|
| 512 |
+
|
| 513 |
+
self._engines.clear()
|
| 514 |
+
self._redis_pools.clear()
|
| 515 |
+
self._stats.clear()
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
# Global instance
|
| 519 |
+
connection_pool_service = ConnectionPoolService()
|
tests/unit/middleware/test_compression.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for compression middleware and service."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
import gzip
|
| 5 |
+
import json
|
| 6 |
+
from fastapi import FastAPI, Response
|
| 7 |
+
from fastapi.responses import StreamingResponse
|
| 8 |
+
from httpx import AsyncClient
|
| 9 |
+
import asyncio
|
| 10 |
+
|
| 11 |
+
from src.services.compression_service import CompressionService, CompressionAlgorithm
|
| 12 |
+
from src.api.middleware.compression import CompressionMiddleware
|
| 13 |
+
from src.api.middleware.streaming_compression import compress_streaming_response
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TestCompressionService:
|
| 17 |
+
"""Test compression service."""
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def compression_service(self):
|
| 21 |
+
"""Create compression service instance."""
|
| 22 |
+
return CompressionService()
|
| 23 |
+
|
| 24 |
+
def test_compress_gzip(self, compression_service):
|
| 25 |
+
"""Test gzip compression."""
|
| 26 |
+
data = b"Hello World! " * 100 # Repeat to ensure compression
|
| 27 |
+
|
| 28 |
+
compressed, encoding, metrics = compression_service.compress(
|
| 29 |
+
data=data,
|
| 30 |
+
content_type="text/plain",
|
| 31 |
+
accept_encoding="gzip"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
assert encoding == "gzip"
|
| 35 |
+
assert len(compressed) < len(data)
|
| 36 |
+
assert metrics["algorithm"] == CompressionAlgorithm.GZIP
|
| 37 |
+
assert metrics["ratio"] > 0.5 # Should achieve >50% compression
|
| 38 |
+
|
| 39 |
+
# Verify can decompress
|
| 40 |
+
decompressed = gzip.decompress(compressed)
|
| 41 |
+
assert decompressed == data
|
| 42 |
+
|
| 43 |
+
def test_compress_below_threshold(self, compression_service):
|
| 44 |
+
"""Test compression with data below threshold."""
|
| 45 |
+
data = b"Small"
|
| 46 |
+
|
| 47 |
+
compressed, encoding, metrics = compression_service.compress(
|
| 48 |
+
data=data,
|
| 49 |
+
content_type="text/plain",
|
| 50 |
+
accept_encoding="gzip"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
assert encoding == "identity"
|
| 54 |
+
assert compressed == data
|
| 55 |
+
assert metrics["reason"] == "below_min_size"
|
| 56 |
+
|
| 57 |
+
def test_algorithm_selection(self, compression_service):
|
| 58 |
+
"""Test algorithm selection based on accept-encoding."""
|
| 59 |
+
data = b"Test data " * 100
|
| 60 |
+
|
| 61 |
+
# Test with multiple encodings
|
| 62 |
+
compressed, encoding, metrics = compression_service.compress(
|
| 63 |
+
data=data,
|
| 64 |
+
content_type="application/json",
|
| 65 |
+
accept_encoding="gzip, deflate, br;q=0.9"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Should prefer br if available, otherwise gzip
|
| 69 |
+
assert encoding in ["br", "gzip"]
|
| 70 |
+
assert len(compressed) < len(data)
|
| 71 |
+
|
| 72 |
+
def test_content_type_profiles(self, compression_service):
|
| 73 |
+
"""Test different compression profiles for content types."""
|
| 74 |
+
data = b'{"key": "value"}' * 100
|
| 75 |
+
|
| 76 |
+
# JSON should use optimal settings
|
| 77 |
+
compressed, encoding, metrics = compression_service.compress(
|
| 78 |
+
data=data,
|
| 79 |
+
content_type="application/json",
|
| 80 |
+
accept_encoding="gzip"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
assert encoding == "gzip"
|
| 84 |
+
assert metrics["ratio"] > 0.8 # JSON compresses very well
|
| 85 |
+
|
| 86 |
+
def test_metrics_tracking(self, compression_service):
|
| 87 |
+
"""Test metrics tracking."""
|
| 88 |
+
# Perform several compressions
|
| 89 |
+
for _ in range(5):
|
| 90 |
+
compression_service.compress(
|
| 91 |
+
data=b"Test data " * 100,
|
| 92 |
+
content_type="text/plain",
|
| 93 |
+
accept_encoding="gzip"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
metrics = compression_service.get_metrics()
|
| 97 |
+
|
| 98 |
+
assert metrics["total_requests"] == 5
|
| 99 |
+
assert metrics["total_bytes_saved"] > 0
|
| 100 |
+
assert "text/plain" in metrics["content_types"]
|
| 101 |
+
assert CompressionAlgorithm.GZIP in metrics["algorithms"]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@pytest.mark.asyncio
|
| 105 |
+
class TestCompressionMiddleware:
|
| 106 |
+
"""Test compression middleware."""
|
| 107 |
+
|
| 108 |
+
@pytest.fixture
|
| 109 |
+
def app(self):
|
| 110 |
+
"""Create test FastAPI app."""
|
| 111 |
+
app = FastAPI()
|
| 112 |
+
|
| 113 |
+
# Add compression middleware
|
| 114 |
+
app.add_middleware(
|
| 115 |
+
CompressionMiddleware,
|
| 116 |
+
minimum_size=100,
|
| 117 |
+
gzip_level=6
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
@app.get("/text")
|
| 121 |
+
def get_text():
|
| 122 |
+
return Response(
|
| 123 |
+
content="Hello World! " * 50,
|
| 124 |
+
media_type="text/plain"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
@app.get("/json")
|
| 128 |
+
def get_json():
|
| 129 |
+
return {"data": "value " * 50}
|
| 130 |
+
|
| 131 |
+
@app.get("/small")
|
| 132 |
+
def get_small():
|
| 133 |
+
return "Small"
|
| 134 |
+
|
| 135 |
+
@app.get("/stream")
|
| 136 |
+
async def get_stream():
|
| 137 |
+
async def generate():
|
| 138 |
+
for i in range(10):
|
| 139 |
+
yield f"Chunk {i}\n" * 10
|
| 140 |
+
await asyncio.sleep(0.01)
|
| 141 |
+
|
| 142 |
+
return compress_streaming_response(
|
| 143 |
+
generate(),
|
| 144 |
+
content_type="text/plain"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
return app
|
| 148 |
+
|
| 149 |
+
async def test_text_compression(self, app):
|
| 150 |
+
"""Test text response compression."""
|
| 151 |
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
| 152 |
+
response = await client.get(
|
| 153 |
+
"/text",
|
| 154 |
+
headers={"Accept-Encoding": "gzip"}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
assert response.status_code == 200
|
| 158 |
+
assert response.headers.get("content-encoding") == "gzip"
|
| 159 |
+
assert "vary" in response.headers
|
| 160 |
+
|
| 161 |
+
# Content should be compressed
|
| 162 |
+
assert len(response.content) < len("Hello World! " * 50)
|
| 163 |
+
|
| 164 |
+
async def test_json_compression(self, app):
|
| 165 |
+
"""Test JSON response compression."""
|
| 166 |
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
| 167 |
+
response = await client.get(
|
| 168 |
+
"/json",
|
| 169 |
+
headers={"Accept-Encoding": "gzip"}
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
assert response.status_code == 200
|
| 173 |
+
assert response.headers.get("content-encoding") == "gzip"
|
| 174 |
+
|
| 175 |
+
# Should be able to decode JSON
|
| 176 |
+
data = response.json()
|
| 177 |
+
assert "data" in data
|
| 178 |
+
|
| 179 |
+
async def test_no_compression_small(self, app):
|
| 180 |
+
"""Test no compression for small responses."""
|
| 181 |
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
| 182 |
+
response = await client.get(
|
| 183 |
+
"/small",
|
| 184 |
+
headers={"Accept-Encoding": "gzip"}
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
assert response.status_code == 200
|
| 188 |
+
assert response.headers.get("content-encoding") is None
|
| 189 |
+
assert response.text == "Small"
|
| 190 |
+
|
| 191 |
+
async def test_no_accept_encoding(self, app):
|
| 192 |
+
"""Test response without accept-encoding."""
|
| 193 |
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
| 194 |
+
response = await client.get("/text")
|
| 195 |
+
|
| 196 |
+
assert response.status_code == 200
|
| 197 |
+
assert response.headers.get("content-encoding") is None
|
| 198 |
+
|
| 199 |
+
async def test_streaming_compression(self, app):
|
| 200 |
+
"""Test streaming response compression."""
|
| 201 |
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
| 202 |
+
response = await client.get(
|
| 203 |
+
"/stream",
|
| 204 |
+
headers={"Accept-Encoding": "gzip"}
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
assert response.status_code == 200
|
| 208 |
+
assert response.headers.get("content-encoding") == "gzip"
|
| 209 |
+
|
| 210 |
+
# Decompress and verify content
|
| 211 |
+
content = gzip.decompress(response.content).decode()
|
| 212 |
+
assert "Chunk 0" in content
|
| 213 |
+
assert "Chunk 9" in content
|