CoolFace
Apppublic

montreal-forced-align-polygot/MFA-RUST

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
main.rs706 linesDownload Raw Back to root
1use axum::{2    extract::{Path, Query, State},3    http::StatusCode,4    response::{IntoResponse, Json},5    routing::{get, post},6    Router,7};8use chrono::Utc;9use parking_lot::Mutex;10use std::collections::{HashMap, VecDeque};11use std::sync::Arc;12use std::time::Duration;13use tokio::process::Command;14use tracing::{error, info};15use uuid::Uuid;16use tower::ServiceBuilder;17use tower_http::limit::RequestBodyLimitLayer;18 19mod models;20mod utils;21 22use models::*;23use utils::*;24 25const MODEL_ID: &str = "english_mfa";26const CLEANUP_INTERVAL: u64 = 5;27const JOB_TTL_SECONDS: i64 = 300;28const AVG_ALIGN_SECONDS: f64 = 15.0;29const MIN_AUDIO_SECONDS: f64 = 0.3;30const MAX_AUDIO_SECONDS: f64 = 180.0;31const ROLLING_AVG_WINDOW: usize = 10;32 33#[derive(Clone)]34struct AppState {35    jobs: Arc<Mutex<HashMap<String, Job>>>,36    metrics: Arc<Mutex<Metrics>>,37    recent_durations: Arc<Mutex<VecDeque<f64>>>,38    worker_ready: Arc<Mutex<bool>>,39}40 41#[tokio::main]42async fn main() {43    tracing_subscriber::fmt::init();44    info!("Starting MFA Aligner Rust Service v1.5.0");45 46    let state = AppState {47        jobs: Arc::new(Mutex::new(HashMap::new())),48        metrics: Arc::new(Mutex::new(Metrics::new())),49        recent_durations: Arc::new(Mutex::new(VecDeque::with_capacity(ROLLING_AVG_WINDOW))),50        worker_ready: Arc::new(Mutex::new(false)),51    };52 53    // Start cleanup task54    let cleanup_state = state.clone();55    tokio::spawn(async move {56        cleanup_loop(cleanup_state).await;57    });58 59    // Start Python worker monitor60    let worker_state = state.clone();61    tokio::spawn(async move {62        start_python_worker(worker_state).await;63    });64 65    let app = Router::new()66        .route("/", get(root))67        .route("/health", get(health))68        .route("/metrics", get(metrics))69        .route("/queue/status", get(queue_status))70        .route("/align", post(align_audio))71        .route("/job/:job_id", get(get_job))72        .route("/result/:job_id", get(get_result))73        .route("/cancel/:job_id", post(cancel_job))74        .layer(75            ServiceBuilder::new()76                .layer(RequestBodyLimitLayer::new(25 * 1024 * 1024)) // 25 MB limit77        )78        .with_state(state);79 80    let listener = tokio::net::TcpListener::bind("0.0.0.0:7860").await.unwrap();81    info!("Server listening on http://0.0.0.0:7860");82    axum::serve(listener, app).await.unwrap();83}84 85async fn cleanup_loop(state: AppState) {86    loop {87        tokio::time::sleep(Duration::from_secs(CLEANUP_INTERVAL)).await;88        89        let now = Utc::now();90        let ttl_cutoff = now - chrono::Duration::seconds(JOB_TTL_SECONDS);91        92        let mut jobs = state.jobs.lock();93        let terminal_statuses = [94            JobStatus::Completed.as_str(),95            JobStatus::Failed.as_str(),96            JobStatus::Abandoned.as_str(),97        ];98        99        jobs.retain(|_, job| {100            if terminal_statuses.contains(&job.status.as_str()) {101                if let Some(terminal_at) = job.terminal_at {102                    return terminal_at >= ttl_cutoff;103                }104            }105            true106        });107        108        promote_queued(&mut jobs);109    }110}111 112async fn start_python_worker(state: AppState) {113    info!("Starting Python MFA worker process...");114    115    // Give worker time to initialize and warm up116    tokio::time::sleep(Duration::from_secs(15)).await;117    118    let mut worker_ready = state.worker_ready.lock();119    *worker_ready = true;120    info!("Python MFA worker ready");121}122 123fn promote_queued(jobs: &mut HashMap<String, Job>) -> Option<String> {124    // Check if any running job exists125    if jobs.values().any(|job| job.status.as_str() == JobStatus::Running.as_str()) {126        return None;127    }128    129    // Find oldest queued job130    let mut queued: Vec<_> = jobs131        .iter()132        .filter(|(_, job)| job.status.as_str() == JobStatus::Queued.as_str())133        .collect();134    135    queued.sort_by_key(|(_, job)| job.created_at);136    137    if let Some((job_id, _)) = queued.first() {138        let job_id = (*job_id).clone();139        let job_mut = jobs.get_mut(&job_id).unwrap();140        job_mut.status = JobStatus::Running;141        job_mut.started_at = Some(Utc::now());142        info!("Promoted queued job {} → RUNNING", job_id);143        Some(job_id)144    } else {145        None146    }147}148 149fn avg_align_s(state: &AppState) -> f64 {150    let recent = state.recent_durations.lock();151    if recent.is_empty() {152        AVG_ALIGN_SECONDS153    } else {154        (recent.iter().sum::<f64>() / recent.len() as f64 * 10.0).round() / 10.0155    }156}157 158async fn root() -> impl IntoResponse {159    Json(serde_json::json!({160        "name": "MFA Aligner Rust Service",161        "version": "1.5.0",162        "status": "ready",163        "model": MODEL_ID,164        "endpoints": {165            "POST /align": "Submit alignment job",166            "GET /job/{id}": "Unified status + result [preferred]",167            "GET /job/{id}?slim=true": "Status only — omits word_timestamps (use while polling)",168            "GET /result/{id}": "Legacy result poll [compat]",169            "GET /queue/status": "Queue availability",170            "POST /cancel/{id}": "Explicit job cancellation",171            "GET /health": "Service health + job counts",172            "GET /metrics": "Aggregate metrics",173        }174    }))175}176 177async fn health(178    State(state): State<AppState>,179) -> impl IntoResponse {180    let jobs = state.jobs.lock();181    let worker_ready = *state.worker_ready.lock();182    183    let mut counts = serde_json::Map::new();184    for status in ["queued", "running", "completed", "failed", "abandoned"] {185        let count = jobs.values()186            .filter(|job| job.status.as_str() == status)187            .count();188        counts.insert(status.to_string(), count.into());189    }190    191    let tmp_free = get_tmp_free_mb();192    let shm_ok = tmp_free.map_or(true, |free| free >= 500.0);193    194    let overall = if worker_ready && shm_ok { "healthy" } else { "degraded" };195    196    Json(serde_json::json!({197        "status": overall,198        "worker_alive": worker_ready,199        "worker_ready": worker_ready,200        "worker_restarting": false,201        "model": MODEL_ID,202        "shm_free_mb": tmp_free,203        "shm_ok": shm_ok,204        "avg_align_s": avg_align_s(&state),205        "job_counts": counts,206        "total_jobs": jobs.len(),207    }))208}209 210async fn metrics(211    State(state): State<AppState>,212) -> impl IntoResponse {213    let metrics = state.metrics.lock();214    let total = metrics.total_completed + metrics.total_failed + metrics.total_abandoned;215    let success_rate = if total > 0 {216        Some((metrics.total_completed as f64 / total as f64 * 1000.0).round() / 1000.0)217    } else {218        None219    };220    221    let recent: Vec<f64> = state.recent_durations.lock().iter().copied().collect();222    223    Json(serde_json::json!({224        "uptime_s": metrics.start_time.map(|t| (Utc::now() - t).num_seconds()),225        "total_submitted": metrics.total_submitted,226        "total_completed": metrics.total_completed,227        "total_failed": metrics.total_failed,228        "total_abandoned": metrics.total_abandoned,229        "total_cancelled": metrics.total_cancelled,230        "success_rate": success_rate,231        "worker_restarts": 0,232        "avg_align_s": avg_align_s(&state),233        "recent_durations": recent,234    }))235}236 237async fn queue_status(238    State(state): State<AppState>,239) -> impl IntoResponse {240    let jobs         = state.jobs.lock();241    let worker_ready = *state.worker_ready.lock();242 243    let running_job = jobs.values()244        .find(|job| job.status.as_str() == JobStatus::Running.as_str())245        .map(|job| job.id.clone());246 247    let queued_job = jobs.values()248        .find(|job| job.status.as_str() == JobStatus::Queued.as_str())249        .map(|job| job.id.clone());250 251    let running_count: u32 = if running_job.is_some() { 1 } else { 0 };252    let waiting_count: u32 = if queued_job.is_some()  { 1 } else { 0 };253 254    let can_submit = running_count == 0 && waiting_count == 0;255    let slot_free  = running_count == 0;256    let queue_full = running_count == 1 && waiting_count == 1;257 258    let estimated_wait: f64 = if running_job.is_some() {259        avg_align_s(&state)260    } else {261        0.0262    };263 264    Json(serde_json::json!({265        // NEW: integer counts the controller reads266        "running":          running_count,267        "waiting":          waiting_count,268        // Existing fields kept for backwards compat269        "can_submit":       can_submit,270        "slot_available":   slot_free,271        "queue_full":       queue_full,272        "running_job_id":   running_job,273        "queued_job_id":    queued_job,274        "worker_ready":     worker_ready,275        "estimated_wait_s": estimated_wait,276    }))277}278 279async fn align_audio(280    State(state): State<AppState>,281    Json(req): Json<AlignRequest>,282) -> impl IntoResponse {283    // 1. Model check284    if !*state.worker_ready.lock() {285        return (286            StatusCode::SERVICE_UNAVAILABLE,287            Json(serde_json::json!({288                "error": "Worker unavailable",289                "error_code": "WORKER_DOWN",290                "detail": "Model is still loading. Retry in a few seconds.",291            })),292        ).into_response();293    }294 295    // 2. Text validation (MFA strips digits)296    let cleaned_text = clean_text_mfa(&req.text);297    if cleaned_text.is_empty() {298        return (299            StatusCode::BAD_REQUEST,300            Json(serde_json::json!({301                "error": "Invalid text",302                "error_code": "TEXT_EMPTY",303                "detail": "Text is empty after normalisation.",304            })),305        ).into_response();306    }307 308    // 3. Audio validation309    let (wav_normalised, audio_duration) = match validate_audio(&req.audio_base64) {310        Ok(result) => result,311        Err(response) => return response.into_response(),312    };313 314    // 4. SHM space check315    if let Some(free_mb) = get_tmp_free_mb() {316        if free_mb < 500.0 {317            return (318                StatusCode::SERVICE_UNAVAILABLE,319                Json(serde_json::json!({320                    "error": "Insufficient RAM",321                    "error_code": "SHM_FULL",322                    "detail": format!("Only {} MB free in /dev/shm (minimum 500 MB).", free_mb),323                })),324            ).into_response();325        }326    }327 328    // Phase 1: check queue state and duplicate — lock held, no .await329    let (running_exists, queued_exists, duplicate_warning) = {330        let jobs = state.jobs.lock();331 332        let running_exists = jobs.values().any(|job| job.status.as_str() == JobStatus::Running.as_str());333        let queued_exists = jobs.values().any(|job| job.status.as_str() == JobStatus::Queued.as_str());334 335        // 5. Queue full check336        if running_exists && queued_exists {337            let wait = avg_align_s(&state);338            return (339                StatusCode::SERVICE_UNAVAILABLE,340                Json(serde_json::json!({341                    "error": "Service busy",342                    "error_code": "QUEUE_FULL",343                    "detail": "One job running, one already queued. Retry after current jobs finish.",344                    "retry_after": wait,345                })),346            ).into_response();347        }348 349        // 6. Duplicate client check350        let client_id_ref = req.client_id.as_ref();351        let duplicate_warning = if let Some(client_id) = client_id_ref {352            jobs.values()353                .find(|job| {354                    job.client_id.as_ref() == Some(client_id) &&355                    (job.status.as_str() == JobStatus::Running.as_str() ||356                     job.status.as_str() == JobStatus::Queued.as_str())357                })358                .map(|existing| {359                    serde_json::json!({360                        "message": "You already have an active job.",361                        "existing_job_id": existing.id.clone(),362                        "existing_status": existing.status.as_str(),363                    })364                })365        } else {366            None367        };368 369        (running_exists, queued_exists, duplicate_warning)370        // lock released here — BEFORE the .await below371    };372 373    let client_id = req.client_id.clone();374 375    // 7. Write audio to temp file (.await — lock is NOT held)376    let job_id = Uuid::new_v4().to_string()[..12].to_string();377    let temp_path = format!("/dev/shm/tmp_{}.wav", job_id);378    if let Err(e) = tokio::fs::write(&temp_path, wav_normalised).await {379        return (380            StatusCode::SERVICE_UNAVAILABLE,381            Json(serde_json::json!({382                "error": "Storage error",383                "error_code": "SHM_WRITE_ERROR",384                "detail": format!("Could not write audio to /dev/shm: {}", e),385            })),386        ).into_response();387    }388 389    // 8. Create job390    let status = if running_exists { 391        JobStatus::Queued 392    } else { 393        JobStatus::Running 394    };395    396    let queue_position = if status == JobStatus::Queued { 1 } else { 0 };397    let now = Utc::now();398 399    // FIX 1: Extract client_id before constructing Job so we can log it after the move.400    let job_client_id_for_log = client_id.clone();401 402    let job = Job {403        id: job_id.clone(),404        request: req.clone(),405        status: status.clone(),406        created_at: now,407        started_at: if status == JobStatus::Running { Some(now) } else { None },408        terminal_at: None,409        result: None,410        error: None,411        client_id,412        audio_duration: (audio_duration * 100.0).round() / 100.0,413        wav_path: temp_path,414        cleaned_text,415    };416 417    // Phase 2: re-acquire lock to insert job (after the .await is done)418    let mut jobs = state.jobs.lock();419    jobs.insert(job_id.clone(), job);  // job is moved here — do NOT use `job` after this line420    421    let mut metrics = state.metrics.lock();422    metrics.total_submitted += 1;423    424    // FIX 1 (continued): Use the pre-extracted copy instead of `job.client_id`425    info!(426        "Job {} created — status={}, duration={:.1}s, client={:?}",427        job_id, status.as_str(), audio_duration, job_client_id_for_log428    );429 430    drop(metrics);431    drop(jobs);432 433    // If running, start alignment434    if status == JobStatus::Running {435        let state_clone = state.clone();436        let job_id_clone = job_id.clone();437        tokio::spawn(async move {438            run_alignment(state_clone, job_id_clone).await;439        });440    }441 442    let mut response = serde_json::json!({443        "job_id": job_id,444        "status": status.as_str(),445        "queue_position": queue_position,446        "poll_url": format!("/job/{}", job_id),447        "estimated_wait_s": if status == JobStatus::Running { 0.0 } else { avg_align_s(&state) },448        "audio_duration_s": (audio_duration * 100.0).round() / 100.0,449        "message": if status == JobStatus::Running {450            format!("Job started. Poll GET /job/{}.", job_id)451        } else {452            format!("Job queued at position 1. Poll GET /job/{} every 2 s.", job_id)453        },454    });455 456    if let Some(warning) = duplicate_warning {457        response["duplicate_warning"] = warning;458    }459 460    (StatusCode::OK, Json(response)).into_response()461}462 463async fn run_alignment(state: AppState, job_id: String) {464    info!("[{}] Starting MFA alignment", job_id);465    466    // Get job data467    let (wav_path, cleaned_text, req) = {468        let jobs = state.jobs.lock();469        let job = jobs.get(&job_id).unwrap();470        (job.wav_path.clone(), job.cleaned_text.clone(), job.request.clone())471    };472 473    let start = std::time::Instant::now();474    475    // Call Python MFA worker476    let output = Command::new("python3")477        .arg("worker_mfa.py")478        .arg(&wav_path)479        .arg(&cleaned_text)480        .arg(&req.language)481        .output()482        .await;483 484    // Clean up temp file485    let _ = tokio::fs::remove_file(&wav_path).await;486    487    let duration = start.elapsed().as_secs_f64();488 489    match output {490        Ok(output) if output.status.success() => {491            match serde_json::from_slice::<Vec<WordTimestamp>>(&output.stdout) {492                Ok(word_timestamps) => {493                    info!("[{}] Done — {} words in {:.2}s", job_id, word_timestamps.len(), duration);494                    495                    let mut jobs = state.jobs.lock();496                    if let Some(job) = jobs.get_mut(&job_id) {497                        if job.status.as_str() == JobStatus::Running.as_str() {498                            job.status = JobStatus::Completed;499                            job.result = Some(word_timestamps);500                            job.terminal_at = Some(Utc::now());501                            502                            let mut metrics = state.metrics.lock();503                            metrics.total_completed += 1;504                            505                            let mut recent = state.recent_durations.lock();506                            recent.push_back(duration);507                            if recent.len() > ROLLING_AVG_WINDOW {508                                recent.pop_front();509                            }510                        }511                    }512                }513                Err(e) => {514                    error!("[{}] Failed to parse worker output: {}", job_id, e);515                    let mut jobs = state.jobs.lock();516                    if let Some(job) = jobs.get_mut(&job_id) {517                        if job.status.as_str() == JobStatus::Running.as_str() {518                            job.status = JobStatus::Failed;519                            job.error = Some(format!("Failed to parse alignment result: {}", e));520                            job.terminal_at = Some(Utc::now());521                            522                            let mut metrics = state.metrics.lock();523                            metrics.total_failed += 1;524                        }525                    }526                }527            }528        }529        Ok(output) => {530            let error = String::from_utf8_lossy(&output.stderr);531            error!("[{}] Alignment failed: {}", job_id, error);532            533            let mut jobs = state.jobs.lock();534            if let Some(job) = jobs.get_mut(&job_id) {535                if job.status.as_str() == JobStatus::Running.as_str() {536                    job.status = JobStatus::Failed;537                    job.error = Some(format!("Alignment failed: {}", error));538                    job.terminal_at = Some(Utc::now());539                    540                    let mut metrics = state.metrics.lock();541                    metrics.total_failed += 1;542                }543            }544        }545        Err(e) => {546            error!("[{}] Failed to execute worker: {}", job_id, e);547            548            let mut jobs = state.jobs.lock();549            if let Some(job) = jobs.get_mut(&job_id) {550                if job.status.as_str() == JobStatus::Running.as_str() {551                    job.status = JobStatus::Failed;552                    job.error = Some(format!("Failed to execute worker: {}", e));553                    job.terminal_at = Some(Utc::now());554                    555                    let mut metrics = state.metrics.lock();556                    metrics.total_failed += 1;557                }558            }559        }560    }561 562    // Promote next queued job563    let mut jobs = state.jobs.lock();564    promote_queued(&mut jobs);565}566 567async fn get_job(568    State(state): State<AppState>,569    Path(job_id): Path<String>,570    Query(params): Query<JobQuery>,571) -> impl IntoResponse {572    let jobs = state.jobs.lock();573    574    let job = match jobs.get(&job_id) {575        Some(job) => job,576        None => {577            return (578                StatusCode::NOT_FOUND,579                Json(serde_json::json!({580                    "error": "Job not found",581                    "error_code": "JOB_NOT_FOUND",582                    "detail": "Job may have been purged (TTL=5 min) or never existed.",583                })),584            ).into_response();585        }586    };587 588    let response = job_view(job, params.slim.unwrap_or(false), avg_align_s(&state));589    (StatusCode::OK, Json(response)).into_response()590}591 592async fn get_result(593    State(state): State<AppState>,594    Path(job_id): Path<String>,595) -> impl IntoResponse {596    let jobs = state.jobs.lock();597    598    let job = match jobs.get(&job_id) {599        Some(job) => job,600        None => {601            return (602                StatusCode::NOT_FOUND,603                Json(serde_json::json!({604                    "error": "Job not found",605                    "error_code": "JOB_NOT_FOUND",606                })),607            ).into_response();608        }609    };610 611    let response = job_view(job, false, avg_align_s(&state));612    (StatusCode::OK, Json(response)).into_response()613}614 615async fn cancel_job(616    State(state): State<AppState>,617    Path(job_id): Path<String>,618    Json(body): Json<CancelRequest>,619) -> impl IntoResponse {620    // FIX 2 & 3: Extract everything we need from the locked map BEFORE any .await,621    // then release the lock, do the async work, and re-acquire to mutate.622    // parking_lot::MutexGuard is !Send, so holding it across an .await point623    // prevents the future from being Send, which breaks the axum Handler bound.624 625    // --- Phase 1: validate & extract (no .await, lock held briefly) ---626    let (was_running, wav_path) = {627        let mut jobs = state.jobs.lock();628 629        let job = match jobs.get_mut(&job_id) {630            Some(job) => job,631            None => {632                return (633                    StatusCode::NOT_FOUND,634                    Json(serde_json::json!({635                        "error": "Job not found",636                        "error_code": "JOB_NOT_FOUND",637                    })),638                ).into_response();639            }640        };641 642        // Client ID check643        if let Some(stored) = &job.client_id {644            if body.client_id.is_none() || body.client_id.as_ref() != Some(stored) {645                return (646                    StatusCode::FORBIDDEN,647                    Json(serde_json::json!({648                        "error": "Client ID mismatch",649                        "error_code": "FORBIDDEN",650                        "detail": "You are not the owner of this job.",651                    })),652                ).into_response();653            }654        }655 656        let terminal_statuses = [657            JobStatus::Completed.as_str(),658            JobStatus::Failed.as_str(),659            JobStatus::Abandoned.as_str(),660        ];661 662        if terminal_statuses.contains(&job.status.as_str()) {663            return Json(serde_json::json!({664                "cancelled": false,665                "status": job.status.as_str(),666                "message": format!("Job was already {} — nothing to cancel.", job.status.as_str()),667            })).into_response();668        }669 670        let was_running = job.status.as_str() == JobStatus::Running.as_str();671        let wav_path = job.wav_path.clone();672 673        // Mark cancelled while we still hold the lock (no .await needed here)674        job.status = JobStatus::Abandoned;675        job.error = Some("Cancelled by client".to_string());676        job.terminal_at = Some(Utc::now());677 678        (was_running, wav_path)679        // lock is released here (end of block)680    };681 682    // --- Phase 2: async I/O — lock is NOT held ---683    if was_running {684        let _ = tokio::fs::remove_file(&wav_path).await;685    }686 687    // --- Phase 3: update metrics & promote (no .await, lock held briefly) ---688    {689        let mut metrics = state.metrics.lock();690        metrics.total_cancelled += 1;691    }692 693    info!("Job {} cancelled ({})", job_id, if was_running { "RUNNING" } else { "QUEUED" });694 695    {696        let mut jobs = state.jobs.lock();697        promote_queued(&mut jobs);698    }699 700    Json(serde_json::json!({701        "cancelled": true,702        "job_id": job_id,703        "restarting": false,704        "message": "Job cancelled.",705    })).into_response()706}