diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 5d36ce607..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [ "main", "develop" ] - pull_request: - branches: [ "main", "develop" ] - schedule: - - cron: '0 0 * * 0' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - config: | - name: Default setup - queries: - - uses: security-and-quality - - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" diff --git a/dependencies/requirements-ml.txt b/dependencies/requirements-ml.txt index 01ae37445..a8d869df4 100644 --- a/dependencies/requirements-ml.txt +++ b/dependencies/requirements-ml.txt @@ -3,32 +3,31 @@ # Exact mirror of pyproject.toml ml extra # ############################################ -# Core ML/AI Dependencies -torch>=2.0.0,<3.0.0 -transformers>=4.55.0,<5.0.0 -datasets>=2.14.0,<5.0.0 accelerate>=0.20.0,<1.0.0 -onnx>=1.14.0,<2.0.0 -onnxruntime>=1.22.1,<2.0.0 -sentencepiece>=0.1.99,<1.0.0 -tokenizers>=0.21.4,<1.0.0 - -# Deep Learning Frameworks -scikit-learn>=1.3.0,<2.0.0 -pandas>=2.0.0,<3.0.0 -numpy>=1.24.0,<3.0.0 -scipy>=1.11.0,<2.0.0 +datasets>=2.14.0,<5.0.0 +gensim>=4.3.0,<5.0.0 +httpx>=0.25.0,<0.29.0 # Text Processing nltk>=3.8,<4.0.0 -spacy>=3.6.0,<4.0.0 -gensim>=4.3.0,<5.0.0 -textblob>=0.17.0,<1.0.0 +numpy>=1.24.0,<3.0.0 +onnx>=1.14.0,<2.0.0 +onnxruntime>=1.16.0,<2.0.0 +pandas>=2.0.0,<3.0.0 -# Note: For GPU support, use: pip install .[ml-gpu] +# Note: For GPU support, use: pip install .[ml-gpu] # For audio processing, use: pip install .[audio] # HTTP client dependencies for consistency requests==2.32.4 -httpx>=0.25.0,<0.29.0 +# Deep Learning Frameworks +scikit-learn>=1.3.0,<2.0.0 +scipy>=1.11.0,<2.0.0 +sentencepiece>=0.1.99,<1.0.0 +spacy>=3.6.0,<4.0.0 +textblob>=0.17.0,<1.0.0 +tokenizers>=0.21.4,<0.22 +# Core ML/AI Dependencies +torch>=2.0.0,<3.0.0 +transformers>=4.55.0,<5.0.0 diff --git a/deployment/cloud-run/requirements.txt b/deployment/cloud-run/requirements.txt index a98531c17..05150876f 100644 --- a/deployment/cloud-run/requirements.txt +++ b/deployment/cloud-run/requirements.txt @@ -1,8 +1,9 @@ flask>=3.1.1,<4.0.0 flask-restx>=1.3.0,<2.0.0 -torch>=2.7.1,<2.9.0 -transformers>=4.55.0,<5.0.0 gunicorn>=23.0.0,<24.0.0 numpy>=1.24.0,<2.0.0 -scikit-learn>=1.5.0,<2.0.0 +psutil>=5.9.0,<6.0.0 requests==2.32.4 +scikit-learn>=1.5.0,<2.0.0 +torch>=2.7.1,<2.9.0 +transformers>=4.55.0,<5.0.0 diff --git a/deployment/cloud_run/health_monitor.py b/deployment/cloud_run/health_monitor.py new file mode 100644 index 000000000..48ac80e59 --- /dev/null +++ b/deployment/cloud_run/health_monitor.py @@ -0,0 +1,354 @@ +"""Cloud Run Health Monitor - Phase 3 Optimization +Provides comprehensive health checks, graceful shutdown, and monitoring +""" + +import logging +import os +import signal +import sys +import time +import threading +from collections import OrderedDict +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, Optional + +import psutil + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +@dataclass +class HealthMetrics: + """Health check metrics.""" + + status: str + response_time_ms: float + memory_usage_mb: float + cpu_usage_percent: float + active_requests: int + timestamp: datetime + error_message: Optional[str] = None + + +class HealthMonitor: + """Comprehensive health monitoring for Cloud Run.""" + + # Class constants for configuration + MODULES_TO_CHECK = [ + "src.models.emotion_detection.bert_classifier", + "src.models.summarization.t5_summarizer", + "src.models.voice_processing.whisper_transcriber", + ] + + # Resource thresholds + MEMORY_THRESHOLD_MB = 1500 # 1.5GB threshold + CPU_THRESHOLD_PERCENT = 80 # 80% CPU threshold + + # Metrics retention + MAX_METRICS_COUNT = 100 + + def __init__(self): + self.start_time = datetime.now() + self.is_shutting_down = False + self.active_requests = 0 + self.health_metrics: OrderedDict[str, HealthMetrics] = OrderedDict() + self.shutdown_timeout = int( + os.getenv("GRACEFUL_SHUTDOWN_TIMEOUT", "30") + ) + self.lock = threading.Lock() + + # Cache for model imports to avoid re-importing on every health check + self._model_import_cache = {} + self._model_import_cache_timestamp = None + self._model_cache_ttl = 300 # 5 minutes + + # Cache for FastAPI app to avoid repeated imports + self._flask_app = None + self._flask_app_import_error = None + self._test_client_class = None + + # Register graceful shutdown handlers + # Note: Signal handling should be initialized in the main thread + # In multi-threaded environments or non-main threads, this may not + # work as expected + try: + signal.signal(signal.SIGTERM, self._graceful_shutdown) + signal.signal(signal.SIGINT, self._graceful_shutdown) + logger.info("Signal handlers registered successfully") + except ValueError as e: + # Fallback for non-main thread environments + logger.warning( + "Could not register signal handlers (likely not in main thread): %s", + e, + ) + logger.warning( + "Graceful shutdown may not work in this environment" + ) + + logger.info( + "Health monitor initialized with %ss shutdown timeout", + self.shutdown_timeout, + ) + + def _graceful_shutdown(self, signum, frame): + """Handle graceful shutdown.""" + logger.info( + "Received shutdown signal %s, starting graceful shutdown...", signum + ) + self.is_shutting_down = True + + # Wait for active requests to complete + start_wait = time.time() + while ( + self.active_requests > 0 + and (time.time() - start_wait) < self.shutdown_timeout + ): + logger.info( + "Waiting for %s active requests to complete...", + self.active_requests, + ) + time.sleep(1) + + if self.active_requests > 0: + logger.warning( + "Force shutdown after %ss timeout with %s active requests", + self.shutdown_timeout, + self.active_requests, + ) + else: + logger.info("Graceful shutdown completed successfully") + + sys.exit(0) + + def get_system_metrics(self) -> Dict[str, float]: + """Get current system resource usage.""" + try: + process = psutil.Process() + memory_info = process.memory_info() + + # Use interval parameter for accurate CPU measurement + # First call may return 0.0, so we use a small interval + cpu_percent = process.cpu_percent(interval=0.1) + + return { + "memory_usage_mb": memory_info.rss / 1024 / 1024, + "cpu_usage_percent": cpu_percent, + "memory_percent": process.memory_percent(), + "uptime_seconds": (datetime.now() - self.start_time).total_seconds(), + } + except Exception as e: + logger.error("Error getting system metrics: %s", e) + return { + "memory_usage_mb": 0.0, + "cpu_usage_percent": 0.0, + "memory_percent": 0.0, + "uptime_seconds": 0.0, + } + + def check_model_health(self) -> Dict[str, Any]: + """Check if ML models are loaded and responding.""" + try: + # Test model loading + start_time = time.time() + + # Check if cache is still valid + current_time = time.time() + if (self._model_import_cache_timestamp is None or + current_time - self._model_import_cache_timestamp > + self._model_cache_ttl): + # Cache expired or doesn't exist, refresh it + self._refresh_model_import_cache() + self._model_import_cache_timestamp = current_time + + # Use cached results + if not self._model_import_cache: + return { + "status": "unhealthy", + "error": "No model modules could be imported", + "response_time_ms": (time.time() - start_time) * 1000, + } + + return { + "status": "healthy", + "response_time_ms": (time.time() - start_time) * 1000, + "models_loaded": len(self._model_import_cache), + } + + except Exception as e: + return { + "status": "unhealthy", + "error": f"Model health check failed: {e}", + "response_time_ms": 0, + } + + def _refresh_model_import_cache(self): + """Refresh the model import cache.""" + import importlib + + self._model_import_cache = {} + + for module_name in self.MODULES_TO_CHECK: + try: + module = importlib.import_module(module_name) + self._model_import_cache[module_name] = module + except ImportError as e: + logger.warning("Model module %s not available: %s", module_name, e) + # Don't fail the entire cache refresh for one module + + def _get_test_client(self): + """Get FastAPI test client with lazy loading and caching.""" + if self._flask_app_import_error is not None: + # Previous import failed, don't try again + return None + + if self._flask_app is None: + try: + from secure_api_server import app + from fastapi.testclient import TestClient + self._flask_app = app + self._test_client_class = TestClient + except ImportError as e: + self._flask_app_import_error = e + logger.warning("Could not import FastAPI app: %s", e) + return None + + return self._test_client_class(self._flask_app) + + def check_api_health(self, test_client=None) -> Dict[str, Any]: + """Check API endpoint health. + + Args: + test_client: Optional FastAPI test client to use for testing. + If None, will attempt to import and create one. + """ + try: + start_time = time.time() + + # Use injected test client or create one + if test_client is None: + test_client = self._get_test_client() + if test_client is None: + return { + "status": "unhealthy", + "error": ( + "Could not create FastAPI test client for health check" + ), + "response_time_ms": 0, + } + + # Test internal health endpoint with proper cleanup + with test_client as client: + response = client.get("/health") + response_time = (time.time() - start_time) * 1000 + + if response.status_code == 200: + return { + "status": "healthy", + "response_time_ms": response_time, + "status_code": response.status_code, + } + return { + "status": "unhealthy", + "error": f"Health endpoint returned {response.status_code}", + "response_time_ms": response_time, + "status_code": response.status_code, + } + except Exception as e: + return { + "status": "unhealthy", + "error": f"API health check failed: {e}", + "response_time_ms": 0, + } + + def get_comprehensive_health(self) -> Dict[str, Any]: + """Get comprehensive health status.""" + # Capture current time once for consistency + now = datetime.now() + + # Snapshot shutdown/active requests under lock + with self.lock: + is_shutdown = self.is_shutting_down + active = self.active_requests + + if is_shutdown: + return { + "status": "shutting_down", + "message": "Service is shutting down gracefully", + "active_requests": active, + "timestamp": now.isoformat(), + } + + # Run expensive checks outside the lock to avoid deadlocks + system_metrics = self.get_system_metrics() + model_health = self.check_model_health() + api_health = self.check_api_health() + + # Determine overall health + overall_status = "healthy" + if model_health["status"] != "healthy" or api_health["status"] != "healthy": + overall_status = "unhealthy" + + # Check resource thresholds (only if not already unhealthy) + if overall_status == "healthy": + if system_metrics["memory_usage_mb"] > self.MEMORY_THRESHOLD_MB: + overall_status = "degraded" + + if system_metrics["cpu_usage_percent"] > self.CPU_THRESHOLD_PERCENT: + overall_status = "degraded" + + health_data = { + "status": overall_status, + "timestamp": now.isoformat(), + "uptime_seconds": system_metrics["uptime_seconds"], + "system": { + "memory_usage_mb": round(system_metrics["memory_usage_mb"], 2), + "cpu_usage_percent": round(system_metrics["cpu_usage_percent"], 2), + "memory_percent": round(system_metrics["memory_percent"], 2), + }, + "models": model_health, + "api": api_health, + "requests": { + "active": active, + # Historical count will be updated under lock below + "historical_metrics_count": len(self.health_metrics), + }, + } + + # Store metrics under lock + with self.lock: + timestamp_key = now.isoformat(timespec="microseconds") + self.health_metrics[timestamp_key] = HealthMetrics( + status=overall_status, + response_time_ms=api_health.get("response_time_ms", 0), + memory_usage_mb=system_metrics["memory_usage_mb"], + cpu_usage_percent=system_metrics["cpu_usage_percent"], + active_requests=active, + timestamp=now, + error_message=model_health.get("error") or api_health.get("error"), + ) + if len(self.health_metrics) > self.MAX_METRICS_COUNT: + self.health_metrics.popitem(last=False) + + return health_data + + def request_started(self): + """Track request start.""" + with self.lock: + self.active_requests += 1 + + def request_completed(self): + """Track request completion.""" + with self.lock: + self.active_requests = max(0, self.active_requests - 1) + + +# Global health monitor instance +health_monitor = HealthMonitor() + + +def get_health_monitor() -> HealthMonitor: + """Get the global health monitor instance.""" + return health_monitor diff --git a/docs/site/comprehensive-demo.html b/docs/site/comprehensive-demo.html index 734afd826..05df81eb1 100644 --- a/docs/site/comprehensive-demo.html +++ b/docs/site/comprehensive-demo.html @@ -5,7 +5,7 @@