Improve the UI and training
This commit is contained in:
@@ -4,7 +4,8 @@ Forecasting Operations API - Business operations for forecast generation and pre
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Request, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import date, datetime, timezone
|
||||
import uuid
|
||||
@@ -202,6 +203,97 @@ async def generate_multi_day_forecast(
|
||||
)
|
||||
|
||||
|
||||
async def execute_batch_forecast_background(
|
||||
tenant_id: str,
|
||||
batch_id: str,
|
||||
inventory_product_ids: List[str],
|
||||
forecast_days: int,
|
||||
batch_name: str
|
||||
):
|
||||
"""
|
||||
Background task for batch forecast generation.
|
||||
Prevents blocking the API thread for long-running batch operations.
|
||||
"""
|
||||
logger.info("Starting background batch forecast",
|
||||
batch_id=batch_id,
|
||||
tenant_id=tenant_id,
|
||||
product_count=len(inventory_product_ids))
|
||||
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "forecasting-service")
|
||||
forecasting_service = EnhancedForecastingService(database_manager)
|
||||
|
||||
try:
|
||||
# Update batch status to running
|
||||
async with database_manager.get_session() as session:
|
||||
from app.repositories import PredictionBatchRepository
|
||||
batch_repo = PredictionBatchRepository(session)
|
||||
|
||||
await batch_repo.update(
|
||||
batch_id,
|
||||
{"status": "processing", "completed_products": 0}
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Generate forecasts for all products
|
||||
from app.schemas.forecasts import BatchForecastRequest
|
||||
batch_request = BatchForecastRequest(
|
||||
tenant_id=tenant_id,
|
||||
batch_name=batch_name,
|
||||
inventory_product_ids=inventory_product_ids,
|
||||
forecast_days=forecast_days
|
||||
)
|
||||
|
||||
result = await forecasting_service.generate_batch_forecasts(
|
||||
tenant_id=tenant_id,
|
||||
request=batch_request
|
||||
)
|
||||
|
||||
# Update batch status to completed
|
||||
async with database_manager.get_session() as session:
|
||||
from app.repositories import PredictionBatchRepository
|
||||
batch_repo = PredictionBatchRepository(session)
|
||||
|
||||
await batch_repo.update(
|
||||
batch_id,
|
||||
{
|
||||
"status": "completed",
|
||||
"completed_at": datetime.now(timezone.utc),
|
||||
"completed_products": result.get("successful_forecasts", 0),
|
||||
"failed_products": result.get("failed_forecasts", 0)
|
||||
}
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info("Background batch forecast completed",
|
||||
batch_id=batch_id,
|
||||
successful=result.get("successful_forecasts", 0),
|
||||
failed=result.get("failed_forecasts", 0))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Background batch forecast failed",
|
||||
batch_id=batch_id,
|
||||
error=str(e))
|
||||
|
||||
try:
|
||||
async with database_manager.get_session() as session:
|
||||
from app.repositories import PredictionBatchRepository
|
||||
batch_repo = PredictionBatchRepository(session)
|
||||
|
||||
await batch_repo.update(
|
||||
batch_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"completed_at": datetime.now(timezone.utc),
|
||||
"error_message": str(e)
|
||||
}
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as update_error:
|
||||
logger.error("Failed to update batch status after error",
|
||||
batch_id=batch_id,
|
||||
error=str(update_error))
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_operations_route("batch"),
|
||||
response_model=BatchForecastResponse
|
||||
@@ -211,11 +303,17 @@ async def generate_multi_day_forecast(
|
||||
async def generate_batch_forecast(
|
||||
request: BatchForecastRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
background_tasks: BackgroundTasks = BackgroundTasks(),
|
||||
request_obj: Request = None,
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
enhanced_forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
|
||||
):
|
||||
"""Generate forecasts for multiple products in batch (Admin+ only, quota enforced)"""
|
||||
"""
|
||||
Generate forecasts for multiple products in batch (Admin+ only, quota enforced).
|
||||
|
||||
IMPROVEMENT: Now uses background tasks for large batches to prevent API timeouts.
|
||||
Returns immediately with batch_id for status tracking.
|
||||
"""
|
||||
metrics = get_metrics_collector(request_obj)
|
||||
|
||||
try:
|
||||
@@ -258,48 +356,104 @@ async def generate_batch_forecast(
|
||||
error_message=None
|
||||
)
|
||||
|
||||
# Skip rate limiting for service-to-service calls (orchestrator)
|
||||
# Rate limiting is handled at the gateway level for user requests
|
||||
# IMPROVEMENT: For large batches (>5 products), use background task
|
||||
# For small batches, execute synchronously for immediate results
|
||||
batch_name = getattr(request, 'batch_name', f"batch-{datetime.now().strftime('%Y%m%d_%H%M%S')}")
|
||||
forecast_days = getattr(request, 'forecast_days', 7)
|
||||
|
||||
# Create a copy of the request with the actual list of product IDs to forecast
|
||||
# (whether originally provided or fetched from inventory service)
|
||||
from app.schemas.forecasts import BatchForecastRequest
|
||||
updated_request = BatchForecastRequest(
|
||||
tenant_id=tenant_id, # Use the tenant_id from the path parameter
|
||||
batch_name=getattr(request, 'batch_name', f"orchestrator-batch-{datetime.now().strftime('%Y%m%d')}"),
|
||||
inventory_product_ids=inventory_product_ids,
|
||||
forecast_days=getattr(request, 'forecast_days', 7)
|
||||
)
|
||||
|
||||
batch_result = await enhanced_forecasting_service.generate_batch_forecasts(
|
||||
tenant_id=tenant_id,
|
||||
request=updated_request
|
||||
)
|
||||
|
||||
if metrics:
|
||||
metrics.increment_counter("batch_forecasts_success_total")
|
||||
|
||||
logger.info("Batch forecast generated successfully",
|
||||
tenant_id=tenant_id,
|
||||
total_forecasts=batch_result.get('total_forecasts', 0))
|
||||
|
||||
# Convert the service result to BatchForecastResponse format
|
||||
from app.schemas.forecasts import BatchForecastResponse
|
||||
# Create batch record first
|
||||
batch_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
return BatchForecastResponse(
|
||||
id=batch_result.get('id', str(uuid.uuid4())), # Use 'id' field (UUID) instead of 'batch_id' (string)
|
||||
tenant_id=tenant_id,
|
||||
batch_name=updated_request.batch_name,
|
||||
status="completed",
|
||||
total_products=batch_result.get('total_forecasts', 0),
|
||||
completed_products=batch_result.get('successful_forecasts', 0),
|
||||
failed_products=batch_result.get('failed_forecasts', 0),
|
||||
requested_at=now,
|
||||
completed_at=now,
|
||||
processing_time_ms=0,
|
||||
forecasts=[],
|
||||
error_message=None
|
||||
)
|
||||
|
||||
async with enhanced_forecasting_service.database_manager.get_session() as session:
|
||||
from app.repositories import PredictionBatchRepository
|
||||
batch_repo = PredictionBatchRepository(session)
|
||||
|
||||
batch_data = {
|
||||
"tenant_id": tenant_id,
|
||||
"batch_name": batch_name,
|
||||
"total_products": len(inventory_product_ids),
|
||||
"forecast_days": forecast_days,
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
batch = await batch_repo.create_batch(batch_data)
|
||||
batch_id = str(batch.id)
|
||||
await session.commit()
|
||||
|
||||
# Use background task for large batches to prevent API timeout
|
||||
use_background = len(inventory_product_ids) > 5
|
||||
|
||||
if use_background:
|
||||
# Queue background task
|
||||
background_tasks.add_task(
|
||||
execute_batch_forecast_background,
|
||||
tenant_id=tenant_id,
|
||||
batch_id=batch_id,
|
||||
inventory_product_ids=inventory_product_ids,
|
||||
forecast_days=forecast_days,
|
||||
batch_name=batch_name
|
||||
)
|
||||
|
||||
logger.info("Batch forecast queued for background processing",
|
||||
tenant_id=tenant_id,
|
||||
batch_id=batch_id,
|
||||
product_count=len(inventory_product_ids))
|
||||
|
||||
# Return immediately with pending status
|
||||
from app.schemas.forecasts import BatchForecastResponse
|
||||
return BatchForecastResponse(
|
||||
id=batch_id,
|
||||
tenant_id=tenant_id,
|
||||
batch_name=batch_name,
|
||||
status="pending",
|
||||
total_products=len(inventory_product_ids),
|
||||
completed_products=0,
|
||||
failed_products=0,
|
||||
requested_at=now,
|
||||
completed_at=None,
|
||||
processing_time_ms=0,
|
||||
forecasts=None,
|
||||
error_message=None
|
||||
)
|
||||
else:
|
||||
# Small batch - execute synchronously
|
||||
from app.schemas.forecasts import BatchForecastRequest
|
||||
updated_request = BatchForecastRequest(
|
||||
tenant_id=tenant_id,
|
||||
batch_name=batch_name,
|
||||
inventory_product_ids=inventory_product_ids,
|
||||
forecast_days=forecast_days
|
||||
)
|
||||
|
||||
batch_result = await enhanced_forecasting_service.generate_batch_forecasts(
|
||||
tenant_id=tenant_id,
|
||||
request=updated_request
|
||||
)
|
||||
|
||||
if metrics:
|
||||
metrics.increment_counter("batch_forecasts_success_total")
|
||||
|
||||
logger.info("Batch forecast completed synchronously",
|
||||
tenant_id=tenant_id,
|
||||
total_forecasts=batch_result.get('total_forecasts', 0))
|
||||
|
||||
# Convert the service result to BatchForecastResponse format
|
||||
from app.schemas.forecasts import BatchForecastResponse
|
||||
return BatchForecastResponse(
|
||||
id=batch_id,
|
||||
tenant_id=tenant_id,
|
||||
batch_name=batch_name,
|
||||
status="completed",
|
||||
total_products=batch_result.get('total_forecasts', 0),
|
||||
completed_products=batch_result.get('successful_forecasts', 0),
|
||||
failed_products=batch_result.get('failed_forecasts', 0),
|
||||
requested_at=now,
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
processing_time_ms=0,
|
||||
forecasts=[],
|
||||
error_message=None
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
if metrics:
|
||||
@@ -806,3 +960,50 @@ async def preview_tenant_data_deletion(
|
||||
status_code=500,
|
||||
detail=f"Failed to preview tenant data deletion: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/database")
|
||||
async def database_health():
|
||||
"""
|
||||
Database health check endpoint with connection pool monitoring.
|
||||
|
||||
Returns detailed connection pool statistics for monitoring and alerting.
|
||||
Useful for detecting connection pool exhaustion before it causes issues.
|
||||
"""
|
||||
from app.core.database import get_db_health, get_connection_pool_stats
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
# Check database connectivity
|
||||
db_healthy = await get_db_health()
|
||||
|
||||
# Get connection pool statistics
|
||||
pool_stats = await get_connection_pool_stats()
|
||||
|
||||
response = {
|
||||
"service": "forecasting",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"database_connected": db_healthy,
|
||||
"connection_pool": pool_stats,
|
||||
"overall_status": "healthy" if db_healthy and pool_stats.get("status") == "healthy" else "degraded"
|
||||
}
|
||||
|
||||
# Return appropriate status code based on health
|
||||
if not db_healthy or pool_stats.get("status") == "critical":
|
||||
return JSONResponse(status_code=503, content=response)
|
||||
elif pool_stats.get("status") == "warning":
|
||||
return JSONResponse(status_code=200, content=response)
|
||||
else:
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Health check failed", error=str(e))
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"service": "forecasting",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"overall_status": "unhealthy",
|
||||
"error": str(e)
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user