readiness.rs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. //! Engine readiness probes: poll `GET /v1/models` on the head node until
  2. //! the inference API answers — after `sm121 serve` launches containers
  3. //! (the model load can take many minutes) and before `sm121 bench` runs —
  4. //! plus a liveness watch for follow mode (the serve must fail loudly when
  5. //! the containers die mid-stream instead of tailing a dead engine).
  6. use crate::config::{Cluster, Config};
  7. use crate::ssh::run_ssh;
  8. use anyhow::Result;
  9. use std::time::{Duration, Instant};
  10. use tokio::net::TcpStream;
  11. /// `sm121 serve`: big checkpoints load for many minutes.
  12. pub(crate) const SERVE_WAIT_SECS: u64 = 1800;
  13. /// `sm121 bench`: the serve is expected to already be up.
  14. pub(crate) const BENCH_WAIT_SECS: u64 = 900;
  15. /// Liveness watch: probe interval / consecutive misses that mean "died".
  16. const WATCH_POLL_SECS: u64 = 30;
  17. const WATCH_MISSES: u32 = 2;
  18. fn head_host_port(cluster: &Cluster, port: u16) -> (String, u16) {
  19. let head = cluster
  20. .nodes
  21. .iter()
  22. .find(|n| n.rank == 0)
  23. .or_else(|| cluster.nodes.first());
  24. (head.map_or("", |n| n.ssh_ip.as_str()).to_string(), port)
  25. }
  26. fn head_url(cluster: &Cluster, port: u16) -> String {
  27. let head = cluster
  28. .nodes
  29. .iter()
  30. .find(|n| n.rank == 0)
  31. .or_else(|| cluster.nodes.first());
  32. format!(
  33. "http://{}:{}/v1",
  34. head.map_or("", |n| n.ssh_ip.as_str()),
  35. port
  36. )
  37. }
  38. /// Poll `GET <base>/models` until it answers (engine up) or the timeout
  39. /// hits; progress note every 30 s.
  40. pub(crate) async fn wait_ready(base_url: &str, timeout_secs: u64) -> Result<()> {
  41. let client = reqwest::Client::builder()
  42. .timeout(Duration::from_secs(10))
  43. .build()?;
  44. let deadline = Instant::now() + Duration::from_secs(timeout_secs);
  45. let mut since_note = Instant::now();
  46. loop {
  47. if let Ok(r) = client.get(format!("{base_url}/models")).send().await
  48. && r.status().is_success()
  49. {
  50. return Ok(());
  51. }
  52. if since_note.elapsed() >= Duration::from_secs(30) {
  53. since_note = Instant::now();
  54. eprintln!("waiting for API at {base_url} (up to {timeout_secs} s)...");
  55. }
  56. if Instant::now() > deadline {
  57. anyhow::bail!("API at {base_url} not ready after {timeout_secs} s");
  58. }
  59. tokio::time::sleep(Duration::from_secs(5)).await;
  60. }
  61. }
  62. /// Serve-side readiness: the head node's engine API must answer, AND every
  63. /// node's recipe container must stay alive. A container that exits during the
  64. /// (multi-minute) model load is detected within ~30 s and fails the serve fast
  65. /// — instead of polling a dead API until the full timeout.
  66. pub(crate) async fn wait_engine(
  67. config: &Config,
  68. cluster: &Cluster,
  69. port: u16,
  70. timeout_secs: u64,
  71. recipe_name: &str,
  72. ) -> Result<()> {
  73. let client = reqwest::Client::builder()
  74. .timeout(Duration::from_secs(10))
  75. .build()?;
  76. let url = head_url(cluster, port);
  77. let deadline = Instant::now() + Duration::from_secs(timeout_secs);
  78. let mut since_note = Instant::now();
  79. let mut since_alive = Instant::now();
  80. loop {
  81. if let Ok(r) = client.get(format!("{url}/models")).send().await
  82. && r.status().is_success()
  83. {
  84. return Ok(());
  85. }
  86. // Fail fast if a recipe container has exited (the API is dead, so the
  87. // pure poll above would otherwise run to the full timeout).
  88. if since_alive.elapsed() >= Duration::from_secs(30) {
  89. since_alive = Instant::now();
  90. containers_alive(config, cluster, recipe_name).await?;
  91. }
  92. if since_note.elapsed() >= Duration::from_secs(30) {
  93. since_note = Instant::now();
  94. eprintln!("waiting for API at {url} (up to {timeout_secs} s)...");
  95. }
  96. if Instant::now() > deadline {
  97. anyhow::bail!("API at {url} not ready after {timeout_secs} s");
  98. }
  99. tokio::time::sleep(Duration::from_secs(5)).await;
  100. }
  101. }
  102. /// Every node's recipe container must be running. Returns an error naming the
  103. /// dead node/container + a log tail, so a crashed launch fails fast instead of
  104. /// polling a dead API. A node that is itself unreachable is skipped (the API
  105. /// probe covers that case); only a container that has *exited* is a death.
  106. pub(crate) async fn containers_alive(
  107. config: &Config,
  108. cluster: &Cluster,
  109. recipe_name: &str,
  110. ) -> Result<()> {
  111. let mut dead: Vec<String> = Vec::new();
  112. for node in &cluster.nodes {
  113. let cmd = format!(
  114. "docker ps -a --filter label=sm121.recipe={recipe_name} --format '{{{{.Names}}}}|{{{{.Status}}}}'"
  115. );
  116. let out = match run_ssh(config, node, cmd, false).await {
  117. Ok(o) => o,
  118. Err(_) => continue, // node unreachable — not a container death
  119. };
  120. for line in out.lines() {
  121. let (name, status) = match line.trim().split_once('|') {
  122. Some((n, s)) => (n, s.trim()),
  123. None => continue,
  124. };
  125. if !status.starts_with("Up") {
  126. let tail = run_ssh(
  127. config,
  128. node,
  129. format!("docker logs --tail 12 {name} 2>&1 | tail -12"),
  130. false,
  131. )
  132. .await
  133. .unwrap_or_default();
  134. let tail = tail
  135. .lines()
  136. .map(|l| format!(" {l}"))
  137. .collect::<Vec<_>>()
  138. .join("\n");
  139. dead.push(format!(" {} ({}): {}\n{tail}", node.name, name, status));
  140. }
  141. }
  142. }
  143. if !dead.is_empty() {
  144. anyhow::bail!(
  145. "serve crashed before readiness — container(s) exited:\n{}",
  146. dead.join("\n")
  147. );
  148. }
  149. Ok(())
  150. }
  151. /// One-shot liveness: is the engine answering right now?
  152. pub(crate) async fn alive(cluster: &Cluster, port: u16) -> bool {
  153. let client = reqwest::Client::builder()
  154. .timeout(Duration::from_secs(10))
  155. .build()
  156. .ok();
  157. let Some(client) = client else {
  158. return false;
  159. };
  160. client
  161. .get(format!("{}/models", head_url(cluster, port)))
  162. .send()
  163. .await
  164. .is_ok_and(|r| r.status().is_success())
  165. }
  166. /// Follow-mode liveness watch: resolves `Err` once the engine misses
  167. /// `WATCH_MISSES` consecutive probes — the containers died mid-follow.
  168. /// The API port opens only when the engine is ready (weight load can take
  169. /// 15+ min), so a connection-level failure before first readiness is "still
  170. /// loading", not "died"; misses count only after the engine answered once
  171. /// (or the port is listening but /v1/models misbehaves).
  172. pub(crate) async fn watch(cluster: &Cluster, port: u16) -> Result<()> {
  173. let (host, p) = head_host_port(cluster, port);
  174. let mut misses = 0;
  175. let mut ever_ready = false;
  176. loop {
  177. tokio::time::sleep(Duration::from_secs(WATCH_POLL_SECS)).await;
  178. if alive(cluster, port).await {
  179. ever_ready = true;
  180. misses = 0;
  181. } else {
  182. let listening = TcpStream::connect((host.as_str(), p)).await.is_ok();
  183. if !ever_ready && !listening {
  184. continue; // API not open yet — normal while loading
  185. }
  186. misses += 1;
  187. if misses >= WATCH_MISSES {
  188. anyhow::bail!(
  189. "engine stopped answering at {} — containers died mid-follow",
  190. head_url(cluster, port)
  191. );
  192. }
  193. }
  194. }
  195. }