//! Engine readiness probes: poll `GET /v1/models` on the head node until //! the inference API answers — after `sm121 serve` launches containers //! (the model load can take many minutes) and before `sm121 bench` runs — //! plus a liveness watch for follow mode (the serve must fail loudly when //! the containers die mid-stream instead of tailing a dead engine). use crate::config::{Cluster, Config}; use crate::ssh::run_ssh; use anyhow::Result; use std::time::{Duration, Instant}; use tokio::net::TcpStream; /// `sm121 serve`: big checkpoints load for many minutes. pub(crate) const SERVE_WAIT_SECS: u64 = 1800; /// `sm121 bench`: the serve is expected to already be up. pub(crate) const BENCH_WAIT_SECS: u64 = 900; /// Liveness watch: probe interval / consecutive misses that mean "died". const WATCH_POLL_SECS: u64 = 30; const WATCH_MISSES: u32 = 2; fn head_host_port(cluster: &Cluster, port: u16) -> (String, u16) { let head = cluster .nodes .iter() .find(|n| n.rank == 0) .or_else(|| cluster.nodes.first()); (head.map_or("", |n| n.ssh_ip.as_str()).to_string(), port) } fn head_url(cluster: &Cluster, port: u16) -> String { let head = cluster .nodes .iter() .find(|n| n.rank == 0) .or_else(|| cluster.nodes.first()); format!( "http://{}:{}/v1", head.map_or("", |n| n.ssh_ip.as_str()), port ) } /// Poll `GET /models` until it answers (engine up) or the timeout /// hits; progress note every 30 s. pub(crate) async fn wait_ready(base_url: &str, timeout_secs: u64) -> Result<()> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build()?; let deadline = Instant::now() + Duration::from_secs(timeout_secs); let mut since_note = Instant::now(); loop { if let Ok(r) = client.get(format!("{base_url}/models")).send().await && r.status().is_success() { return Ok(()); } if since_note.elapsed() >= Duration::from_secs(30) { since_note = Instant::now(); eprintln!("waiting for API at {base_url} (up to {timeout_secs} s)..."); } if Instant::now() > deadline { anyhow::bail!("API at {base_url} not ready after {timeout_secs} s"); } tokio::time::sleep(Duration::from_secs(5)).await; } } /// Serve-side readiness: the head node's engine API must answer, AND every /// node's recipe container must stay alive. A container that exits during the /// (multi-minute) model load is detected within ~30 s and fails the serve fast /// — instead of polling a dead API until the full timeout. pub(crate) async fn wait_engine( config: &Config, cluster: &Cluster, port: u16, timeout_secs: u64, recipe_name: &str, ) -> Result<()> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build()?; let url = head_url(cluster, port); let deadline = Instant::now() + Duration::from_secs(timeout_secs); let mut since_note = Instant::now(); let mut since_alive = Instant::now(); loop { if let Ok(r) = client.get(format!("{url}/models")).send().await && r.status().is_success() { return Ok(()); } // Fail fast if a recipe container has exited (the API is dead, so the // pure poll above would otherwise run to the full timeout). if since_alive.elapsed() >= Duration::from_secs(30) { since_alive = Instant::now(); containers_alive(config, cluster, recipe_name).await?; } if since_note.elapsed() >= Duration::from_secs(30) { since_note = Instant::now(); eprintln!("waiting for API at {url} (up to {timeout_secs} s)..."); } if Instant::now() > deadline { anyhow::bail!("API at {url} not ready after {timeout_secs} s"); } tokio::time::sleep(Duration::from_secs(5)).await; } } /// Every node's recipe container must be running. Returns an error naming the /// dead node/container + a log tail, so a crashed launch fails fast instead of /// polling a dead API. A node that is itself unreachable is skipped (the API /// probe covers that case); only a container that has *exited* is a death. pub(crate) async fn containers_alive( config: &Config, cluster: &Cluster, recipe_name: &str, ) -> Result<()> { let mut dead: Vec = Vec::new(); for node in &cluster.nodes { let cmd = format!( "docker ps -a --filter label=sm121.recipe={recipe_name} --format '{{{{.Names}}}}|{{{{.Status}}}}'" ); let out = match run_ssh(config, node, cmd, false).await { Ok(o) => o, Err(_) => continue, // node unreachable — not a container death }; for line in out.lines() { let (name, status) = match line.trim().split_once('|') { Some((n, s)) => (n, s.trim()), None => continue, }; if !status.starts_with("Up") { let tail = run_ssh( config, node, format!("docker logs --tail 12 {name} 2>&1 | tail -12"), false, ) .await .unwrap_or_default(); let tail = tail .lines() .map(|l| format!(" {l}")) .collect::>() .join("\n"); dead.push(format!(" {} ({}): {}\n{tail}", node.name, name, status)); } } } if !dead.is_empty() { anyhow::bail!( "serve crashed before readiness — container(s) exited:\n{}", dead.join("\n") ); } Ok(()) } /// One-shot liveness: is the engine answering right now? pub(crate) async fn alive(cluster: &Cluster, port: u16) -> bool { let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .ok(); let Some(client) = client else { return false; }; client .get(format!("{}/models", head_url(cluster, port))) .send() .await .is_ok_and(|r| r.status().is_success()) } /// Follow-mode liveness watch: resolves `Err` once the engine misses /// `WATCH_MISSES` consecutive probes — the containers died mid-follow. /// The API port opens only when the engine is ready (weight load can take /// 15+ min), so a connection-level failure before first readiness is "still /// loading", not "died"; misses count only after the engine answered once /// (or the port is listening but /v1/models misbehaves). pub(crate) async fn watch(cluster: &Cluster, port: u16) -> Result<()> { let (host, p) = head_host_port(cluster, port); let mut misses = 0; let mut ever_ready = false; loop { tokio::time::sleep(Duration::from_secs(WATCH_POLL_SECS)).await; if alive(cluster, port).await { ever_ready = true; misses = 0; } else { let listening = TcpStream::connect((host.as_str(), p)).await.is_ok(); if !ever_ready && !listening { continue; // API not open yet — normal while loading } misses += 1; if misses >= WATCH_MISSES { anyhow::bail!( "engine stopped answering at {} — containers died mid-follow", head_url(cluster, port) ); } } } }