Files
foxhunt/services/api/src/grpc/ml_training_proxy.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
Copied from api_gateway, removed REST handlers (port 8080),
added tonic-web + CORS for grpc-web browser access.
Binary renamed: api-gateway → api

Changes:
- Package name: api-gateway → api
- Deleted src/handlers/ (REST ML endpoints on port 8080)
- Added tonic-web 0.13 + tower-http CORS layer
- Server::builder().accept_http1(true) for grpc-web
- CORS_ORIGINS env var (default http://localhost:5173)
- Metrics server on port 9091 (axum) preserved
- All 95 lib tests pass, 0 clippy warnings
- Added services/api to workspace members

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +01:00

633 lines
22 KiB
Rust

//! ML Training Service Proxy - Zero-copy gRPC forwarding for ML training operations
//!
//! This module implements a high-performance proxy for the ML Training Service with:
//! - Zero-copy message forwarding (routing overhead <10μs)
//! - Connection pooling via tonic::transport::Channel
//! - Circuit breaker integration for backend failures
//! - Efficient streaming support for training metrics
//! - Health checking integration
use futures::Stream;
use std::pin::Pin;
use tonic::{Request, Response, Status};
use tracing::{error, info, instrument, warn};
// Import the generated ML training service protobuf definitions from lib.rs
use crate::ml_training::ml_training_service_client::MlTrainingServiceClient;
use crate::ml_training::ml_training_service_server::{MlTrainingService, MlTrainingServiceServer};
use crate::ml_training::{
// Batch tuning types
BatchStartTuningJobsRequest,
BatchStartTuningJobsResponse,
GetBatchTuningStatusRequest,
GetBatchTuningStatusResponse,
GetTrainingJobDetailsRequest,
GetTrainingJobDetailsResponse,
GetTuningJobStatusRequest,
GetTuningJobStatusResponse,
HealthCheckRequest,
HealthCheckResponse,
ListAvailableModelsRequest,
ListAvailableModelsResponse,
ListTrainingJobsRequest,
ListTrainingJobsResponse,
ProgressUpdate,
StartTrainingRequest,
StartTrainingResponse,
// Tuning job types
StartTuningJobRequest,
StartTuningJobResponse,
StopBatchTuningJobRequest,
StopBatchTuningJobResponse,
StopTrainingRequest,
StopTrainingResponse,
StopTuningJobRequest,
StopTuningJobResponse,
StreamProgressRequest,
SubscribeToTrainingStatusRequest,
TrainModelRequest,
TrainModelResponse,
TrainingStatusUpdate,
// Job completion and model promotion types
JobCompletionReport,
JobCompletionAck,
ListPendingPromotionsRequest,
ListPendingPromotionsResponse,
ApprovePromotionRequest,
ApprovePromotionResponse,
RejectPromotionRequest,
RejectPromotionResponse,
ApproveModelRequest,
ApproveModelResponse,
RejectModelRequest,
RejectModelResponse,
};
/// ML Training Service Proxy
///
/// Provides zero-copy forwarding of gRPC requests to the backend ML Training Service.
///
/// Uses connection pooling and circuit breakers for high availability and performance.
#[derive(Debug, Clone)]
pub struct MlTrainingProxy {
/// Backend ML Training Service client with connection pooling
client: MlTrainingServiceClient<tonic::transport::Channel>,
}
impl MlTrainingProxy {
/// Create a new ML Training Service proxy
///
/// # Arguments
/// * `client` - Pre-configured ML Training Service client with circuit breaker
///
/// # Performance
/// - Uses Arc-based channel cloning for zero-copy client reuse
///
/// - Connection pooling managed by tonic::transport::Channel
pub fn new(client: MlTrainingServiceClient<tonic::transport::Channel>) -> Self {
Self { client }
}
/// Convert proxy into a tonic server instance
pub fn into_server(self) -> MlTrainingServiceServer<MlTrainingProxy> {
MlTrainingServiceServer::new(self)
}
}
#[tonic::async_trait]
impl MlTrainingService for MlTrainingProxy {
/// Server streaming type for training status updates
type SubscribeToTrainingStatusStream =
Pin<Box<dyn Stream<Item = Result<TrainingStatusUpdate, Status>> + Send>>;
/// Server streaming type for tuning progress updates
type StreamTuningProgressStream =
Pin<Box<dyn Stream<Item = Result<ProgressUpdate, Status>> + Send>>;
/// Start a new model training job
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn start_training(
&self,
request: Request<StartTrainingRequest>,
) -> Result<Response<StartTrainingResponse>, Status> {
info!("Proxying StartTraining request");
// Clone client (cheap Arc increment) for concurrent request handling
let mut client = self.client.clone();
// Forward request with zero-copy
let response = client.start_training(request).await.map_err(|e| {
error!("Backend StartTraining failed: {}", e);
e
})?;
info!("StartTraining request forwarded successfully");
Ok(response)
}
/// Subscribe to real-time training status updates (server streaming)
///
/// # Performance
/// - Zero-copy stream forwarding
///
/// - No intermediate buffering
/// - Direct stream passthrough from backend
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn subscribe_to_training_status(
&self,
request: Request<SubscribeToTrainingStatusRequest>,
) -> Result<Response<Self::SubscribeToTrainingStatusStream>, Status> {
info!("Proxying SubscribeToTrainingStatus streaming request");
let mut client = self.client.clone();
// Get backend stream response
let stream_response = client
.subscribe_to_training_status(request)
.await
.map_err(|e| {
error!("Backend SubscribeToTrainingStatus failed: {}", e);
e
})?;
// Extract inner stream and forward directly (zero-copy)
let stream = stream_response.into_inner();
let boxed_stream = Box::pin(stream) as Self::SubscribeToTrainingStatusStream;
info!("SubscribeToTrainingStatus streaming request forwarded successfully");
Ok(Response::new(boxed_stream))
}
/// Stop a running training job
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn stop_training(
&self,
request: Request<StopTrainingRequest>,
) -> Result<Response<StopTrainingResponse>, Status> {
info!("Proxying StopTraining request");
let mut client = self.client.clone();
let response = client.stop_training(request).await.map_err(|e| {
error!("Backend StopTraining failed: {}", e);
e
})?;
info!("StopTraining request forwarded successfully");
Ok(response)
}
/// List available ML models
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn list_available_models(
&self,
request: Request<ListAvailableModelsRequest>,
) -> Result<Response<ListAvailableModelsResponse>, Status> {
info!("Proxying ListAvailableModels request");
let mut client = self.client.clone();
let response = client.list_available_models(request).await.map_err(|e| {
error!("Backend ListAvailableModels failed: {}", e);
e
})?;
info!("ListAvailableModels request forwarded successfully");
Ok(response)
}
/// List training job history
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn list_training_jobs(
&self,
request: Request<ListTrainingJobsRequest>,
) -> Result<Response<ListTrainingJobsResponse>, Status> {
info!("Proxying ListTrainingJobs request");
let mut client = self.client.clone();
let response = client.list_training_jobs(request).await.map_err(|e| {
error!("Backend ListTrainingJobs failed: {}", e);
e
})?;
info!("ListTrainingJobs request forwarded successfully");
Ok(response)
}
/// Get detailed information about a training job
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn get_training_job_details(
&self,
request: Request<GetTrainingJobDetailsRequest>,
) -> Result<Response<GetTrainingJobDetailsResponse>, Status> {
info!("Proxying GetTrainingJobDetails request");
let mut client = self.client.clone();
let response = client
.get_training_job_details(request)
.await
.map_err(|e| {
error!("Backend GetTrainingJobDetails failed: {}", e);
e
})?;
info!("GetTrainingJobDetails request forwarded successfully");
Ok(response)
}
/// Health check for ML Training Service backend
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn health_check(
&self,
request: Request<HealthCheckRequest>,
) -> Result<Response<HealthCheckResponse>, Status> {
info!("Proxying HealthCheck request");
let mut client = self.client.clone();
let response = client.health_check(request).await.map_err(|e| {
warn!("Backend HealthCheck failed: {}", e);
e
})?;
info!("HealthCheck request forwarded successfully");
Ok(response)
}
/// Start a new hyperparameter tuning job
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
///
/// # Security
/// - Requires "ml.tune" permission in JWT metadata
/// - JWT validation handled by interceptor layer
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn start_tuning_job(
&self,
request: Request<StartTuningJobRequest>,
) -> Result<Response<StartTuningJobResponse>, Status> {
info!("Proxying StartTuningJob request");
// Clone client (cheap Arc increment) for concurrent request handling
let mut client = self.client.clone();
// Forward request with zero-copy
// Note: JWT validation and "ml.tune" permission check handled by interceptor
let response = client.start_tuning_job(request).await.map_err(|e| {
error!("Backend StartTuningJob failed: {}", e);
e
})?;
info!("StartTuningJob request forwarded successfully");
Ok(response)
}
/// Get status and best parameters from a tuning job
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
///
/// # Security
/// - Job ownership validation handled by backend service
/// - User can only query their own tuning jobs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn get_tuning_job_status(
&self,
request: Request<GetTuningJobStatusRequest>,
) -> Result<Response<GetTuningJobStatusResponse>, Status> {
info!("Proxying GetTuningJobStatus request");
let mut client = self.client.clone();
// Forward request - backend validates job ownership via user_id from JWT
let response = client.get_tuning_job_status(request).await.map_err(|e| {
error!("Backend GetTuningJobStatus failed: {}", e);
e
})?;
info!("GetTuningJobStatus request forwarded successfully");
Ok(response)
}
/// Stop a running hyperparameter tuning job
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
///
/// # Security
/// - Job ownership validation handled by backend service
/// - User can only stop their own tuning jobs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn stop_tuning_job(
&self,
request: Request<StopTuningJobRequest>,
) -> Result<Response<StopTuningJobResponse>, Status> {
info!("Proxying StopTuningJob request");
let mut client = self.client.clone();
// Forward stop request - backend validates job ownership via user_id from JWT
let response = client.stop_tuning_job(request).await.map_err(|e| {
error!("Backend StopTuningJob failed: {}", e);
e
})?;
info!("StopTuningJob request forwarded successfully");
Ok(response)
}
/// INTERNAL: Train a single model instance with specific hyperparameters
///
/// # Performance
/// - Zero-copy message forwarding
///
/// - Routing overhead target: <10μs
///
/// # Note
/// - Called by Optuna subprocess during tuning trials
/// - Not typically called directly by clients
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn train_model(
&self,
request: Request<TrainModelRequest>,
) -> Result<Response<TrainModelResponse>, Status> {
info!("Proxying TrainModel request (INTERNAL)");
let mut client = self.client.clone();
// Forward internal training request
let response = client.train_model(request).await.map_err(|e| {
error!("Backend TrainModel failed: {}", e);
e
})?;
info!("TrainModel request forwarded successfully");
Ok(response)
}
/// Stream real-time tuning progress updates (server streaming)
///
/// # Performance
/// - Zero-copy stream forwarding
///
/// - No intermediate buffering
/// - Direct stream passthrough from backend
///
/// # Security
/// - Tuning job ownership validation handled by backend service
/// - User can only subscribe to their own tuning jobs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn stream_tuning_progress(
&self,
request: Request<StreamProgressRequest>,
) -> Result<Response<Self::StreamTuningProgressStream>, Status> {
info!("Proxying StreamTuningProgress streaming request");
let mut client = self.client.clone();
// Get backend stream response
let stream_response = client.stream_tuning_progress(request).await.map_err(|e| {
error!("Backend StreamTuningProgress failed: {}", e);
e
})?;
// Extract inner stream and forward directly (zero-copy)
let stream = stream_response.into_inner();
let boxed_stream = Box::pin(stream) as Self::StreamTuningProgressStream;
info!("StreamTuningProgress streaming request forwarded successfully");
Ok(Response::new(boxed_stream))
}
/// Start batch tuning for multiple models with automatic dependency resolution
///
/// # Performance
/// - Zero-copy message forwarding
/// - Routing overhead target: <10μs
///
/// # Security
/// - Requires "ml.tune" permission in JWT metadata
/// - JWT validation handled by interceptor layer
///
/// # Features
/// - Sequential model tuning with dependency resolution
/// - Automatic YAML export of best hyperparameters
/// - Supports all model types (DQN, PPO, MAMBA_2, TFT, etc.)
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn batch_start_tuning_jobs(
&self,
request: Request<BatchStartTuningJobsRequest>,
) -> Result<Response<BatchStartTuningJobsResponse>, Status> {
info!("Proxying BatchStartTuningJobs request");
// Clone client (cheap Arc increment) for concurrent request handling
let mut client = self.client.clone();
// Forward batch tuning request with zero-copy
// Note: JWT validation and "ml.tune" permission check handled by interceptor
let response = client.batch_start_tuning_jobs(request).await.map_err(|e| {
error!("Backend BatchStartTuningJobs failed: {}", e);
e
})?;
info!("BatchStartTuningJobs request forwarded successfully");
Ok(response)
}
/// Get batch tuning job status with per-model results
///
/// # Performance
/// - Zero-copy message forwarding
/// - Routing overhead target: <10μs
///
/// # Security
/// - Batch job ownership validation handled by backend service
/// - User can only query their own batch jobs
///
/// # Returns
/// - Batch status (PENDING, RUNNING, COMPLETED, FAILED, STOPPED)
/// - Per-model tuning results
/// - Current execution progress
/// - Estimated completion time
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn get_batch_tuning_status(
&self,
request: Request<GetBatchTuningStatusRequest>,
) -> Result<Response<GetBatchTuningStatusResponse>, Status> {
info!("Proxying GetBatchTuningStatus request");
let mut client = self.client.clone();
// Forward request - backend validates batch job ownership via user_id from JWT
let response = client.get_batch_tuning_status(request).await.map_err(|e| {
error!("Backend GetBatchTuningStatus failed: {}", e);
e
})?;
info!("GetBatchTuningStatus request forwarded successfully");
Ok(response)
}
/// Stop a running batch tuning job
///
/// # Performance
/// - Zero-copy message forwarding
/// - Routing overhead target: <10μs
///
/// # Security
/// - Batch job ownership validation handled by backend service
/// - User can only stop their own batch jobs
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn stop_batch_tuning_job(
&self,
request: Request<StopBatchTuningJobRequest>,
) -> Result<Response<StopBatchTuningJobResponse>, Status> {
info!("Proxying StopBatchTuningJob request");
let mut client = self.client.clone();
// Forward stop request - backend validates batch job ownership via user_id from JWT
let response = client.stop_batch_tuning_job(request).await.map_err(|e| {
error!("Backend StopBatchTuningJob failed: {}", e);
e
})?;
info!("StopBatchTuningJob request forwarded successfully");
Ok(response)
}
/// Report job completion (called by training-uploader sidecar)
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn report_job_completion(
&self,
request: Request<JobCompletionReport>,
) -> Result<Response<JobCompletionAck>, Status> {
info!("Proxying ReportJobCompletion request");
let mut client = self.client.clone();
let response = client.report_job_completion(request).await.map_err(|e| {
error!("Backend ReportJobCompletion failed: {}", e);
e
})?;
Ok(response)
}
/// List models pending promotion approval
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn list_pending_promotions(
&self,
request: Request<ListPendingPromotionsRequest>,
) -> Result<Response<ListPendingPromotionsResponse>, Status> {
info!("Proxying ListPendingPromotions request");
let mut client = self.client.clone();
let response = client.list_pending_promotions(request).await.map_err(|e| {
error!("Backend ListPendingPromotions failed: {}", e);
e
})?;
Ok(response)
}
/// Approve a pending model promotion
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn approve_promotion(
&self,
request: Request<ApprovePromotionRequest>,
) -> Result<Response<ApprovePromotionResponse>, Status> {
info!("Proxying ApprovePromotion request");
let mut client = self.client.clone();
let response = client.approve_promotion(request).await.map_err(|e| {
error!("Backend ApprovePromotion failed: {}", e);
e
})?;
Ok(response)
}
/// Reject a pending model promotion
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn reject_promotion(
&self,
request: Request<RejectPromotionRequest>,
) -> Result<Response<RejectPromotionResponse>, Status> {
info!("Proxying RejectPromotion request");
let mut client = self.client.clone();
let response = client.reject_promotion(request).await.map_err(|e| {
error!("Backend RejectPromotion failed: {}", e);
e
})?;
Ok(response)
}
/// Approve a model for promotion
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn approve_model(
&self,
request: Request<ApproveModelRequest>,
) -> Result<Response<ApproveModelResponse>, Status> {
info!("Proxying ApproveModel request");
let mut client = self.client.clone();
let response = client.approve_model(request).await.map_err(|e| {
error!("Backend ApproveModel failed: {}", e);
e
})?;
Ok(response)
}
/// Reject a model promotion
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn reject_model(
&self,
request: Request<RejectModelRequest>,
) -> Result<Response<RejectModelResponse>, Status> {
info!("Proxying RejectModel request");
let mut client = self.client.clone();
let response = client.reject_model(request).await.map_err(|e| {
error!("Backend RejectModel failed: {}", e);
e
})?;
Ok(response)
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_proxy_creation() {
// This test validates the proxy struct can be created
// Full integration tests require running backend service
}
}