runlock.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. //! Recipe state bookkeeping:
  2. //!
  3. //! **Run ledger** — `recipes/<name>/.lock` (gitignored, machine state):
  4. //! every `sm121 serve` opens a run (unix-epoch start, cluster), every
  5. //! verified `sm121 stop` closes it with its uptime; a serve over a stale
  6. //! open run (crash skipped `stop`) closes it first. `sm121 report` reads
  7. //! who is up right now, the current uptime and lifetime launch counts.
  8. //!
  9. //! **Archived flag** — `archived = true` in the recipe's own toml
  10. //! (round-trip-safe via `toml_edit`): archived recipes leave the report
  11. //! output until un-archived.
  12. use anyhow::Result;
  13. use serde::{Deserialize, Serialize};
  14. use std::path::{Path, PathBuf};
  15. /// `sm121 runlock` subcommands — offline .lock ledger management. `sm121
  16. /// stop` only closes the ledger after verifying every node of the cluster,
  17. /// so a dead node (or a killed serve) leaves the run open forever; these
  18. /// fix that without touching the nodes.
  19. #[derive(clap::Subcommand)]
  20. pub enum RunlockCommands {
  21. /// Close the open run of a recipe's .lock ledger (offline)
  22. Close {
  23. /// Recipe name (its directory under the recipe sources)
  24. recipe: String,
  25. },
  26. /// List every recipe with an open (running) ledger entry
  27. List,
  28. }
  29. /// One launch: unix-epoch seconds (UTC), open-ended while running.
  30. #[derive(Serialize, Deserialize, Clone)]
  31. pub(crate) struct RunEntry {
  32. pub(crate) start: i64,
  33. #[serde(default, skip_serializing_if = "Option::is_none")]
  34. pub(crate) end: Option<i64>,
  35. #[serde(default, skip_serializing_if = "Option::is_none")]
  36. pub(crate) uptime_secs: Option<i64>,
  37. /// cluster the run landed on (None for tmux deploys)
  38. #[serde(default, skip_serializing_if = "Option::is_none")]
  39. pub(crate) cluster: Option<String>,
  40. }
  41. #[derive(Serialize, Deserialize, Default)]
  42. pub(crate) struct Lock {
  43. /// every launch ever, newest last; capped to the newest [`HISTORY_CAP`]
  44. #[serde(default)]
  45. pub(crate) runs: Vec<RunEntry>,
  46. /// the serve that is up right now (absent when stopped)
  47. #[serde(default, skip_serializing_if = "Option::is_none")]
  48. pub(crate) running: Option<RunEntry>,
  49. /// lifetime launch count — survives history capping
  50. #[serde(default)]
  51. pub(crate) total: u64,
  52. }
  53. const HISTORY_CAP: usize = 100;
  54. impl Lock {
  55. pub(crate) fn load(dir: &Path) -> Self {
  56. std::fs::read_to_string(dir.join(".lock"))
  57. .ok()
  58. .and_then(|s| serde_json::from_str(&s).ok())
  59. .unwrap_or_default()
  60. }
  61. fn save(&self, dir: &Path) -> Result<()> {
  62. std::fs::create_dir_all(dir)?;
  63. std::fs::write(dir.join(".lock"), serde_json::to_string_pretty(self)?)?;
  64. Ok(())
  65. }
  66. }
  67. fn recipe_dir(recipe: &str, sources: &[String]) -> Result<PathBuf> {
  68. crate::config::recipe_file(recipe, sources)
  69. .and_then(|p| p.parent().map(Path::to_path_buf))
  70. .ok_or_else(|| anyhow::anyhow!("recipe dir not found for '{recipe}'"))
  71. }
  72. /// `sm121 serve` went up: close a stale open run (a crash skipped `stop`),
  73. /// then open the new one. `cluster` is None for tmux deploys.
  74. pub(crate) fn mark_start(recipe: &str, sources: &[String], cluster: Option<&str>) -> Result<()> {
  75. let dir = recipe_dir(recipe, sources)?;
  76. let mut l = Lock::load(&dir);
  77. let t = crate::stamp::now_secs();
  78. if let Some(_stale) = l.running.take()
  79. && let Some(last) = l.runs.last_mut()
  80. && last.end.is_none()
  81. {
  82. last.end = Some(t);
  83. last.uptime_secs = Some((t - last.start).max(0));
  84. }
  85. let run = RunEntry {
  86. start: t,
  87. end: None,
  88. uptime_secs: None,
  89. cluster: cluster.map(String::from),
  90. };
  91. l.runs.push(run.clone());
  92. if l.runs.len() > HISTORY_CAP {
  93. l.runs.drain(..l.runs.len() - HISTORY_CAP);
  94. }
  95. l.running = Some(run);
  96. l.total += 1;
  97. l.save(&dir)
  98. }
  99. /// `sm121 stop` verified the containers down: close the open run with its
  100. /// uptime. A stop without a tracked run (foreign serve, already-stopped)
  101. /// is a no-op.
  102. pub(crate) fn mark_stop(recipe: &str, sources: &[String]) -> Result<()> {
  103. let dir = recipe_dir(recipe, sources)?;
  104. let mut l = Lock::load(&dir);
  105. if l.running.take().is_none() {
  106. return Ok(());
  107. }
  108. let t = crate::stamp::now_secs();
  109. if let Some(last) = l.runs.last_mut()
  110. && last.end.is_none()
  111. {
  112. last.end = Some(t);
  113. last.uptime_secs = Some((t - last.start).max(0));
  114. }
  115. l.save(&dir)
  116. }
  117. /// Every recipe with an open (running) ledger entry: (name, start, cluster).
  118. /// Same dir precedence as collect: first dir wins on name collisions.
  119. pub(crate) fn open_runs(config: &crate::config::Config) -> Vec<(String, i64, Option<String>)> {
  120. let mut out = Vec::new();
  121. let mut seen = std::collections::HashSet::new();
  122. for dir in crate::config::recipe_dirs(config) {
  123. let Ok(entries) = std::fs::read_dir(&dir) else {
  124. continue;
  125. };
  126. for entry in entries.flatten() {
  127. let path = entry.path();
  128. let Some(name) = entry.file_name().to_str().map(String::from) else {
  129. continue;
  130. };
  131. if !seen.insert(name.clone()) || !path.join("recipe.toml").is_file() {
  132. continue;
  133. }
  134. if let Some(run) = Lock::load(&path).running {
  135. out.push((name, run.start, run.cluster));
  136. }
  137. }
  138. }
  139. out.sort_by(|a, b| a.0.cmp(&b.0));
  140. out
  141. }
  142. /// `sm121 runlock` — offline ledger management: no node verification
  143. /// (unlike `sm121 stop`, which refuses to close the ledger when it cannot
  144. /// reach every node of the cluster). Used after a node died mid-run or the
  145. /// serve process was killed.
  146. pub(crate) fn run(config: &crate::config::Config, cmd: &RunlockCommands) -> Result<()> {
  147. match cmd {
  148. RunlockCommands::Close { recipe } => {
  149. let open = open_runs(config)
  150. .into_iter()
  151. .find(|(n, _, _)| n == recipe)
  152. .map(|(_, start, cluster)| (start, cluster));
  153. match open {
  154. None => println!("no open run for '{recipe}' (nothing to close)"),
  155. Some((start, cluster)) => {
  156. mark_stop(recipe, &config.recipe_sources)?;
  157. println!(
  158. "closed open run of '{recipe}' (started {} UTC, cluster {})",
  159. crate::stamp::fmt_ts(start),
  160. cluster.as_deref().unwrap_or("tmux"),
  161. );
  162. }
  163. }
  164. }
  165. RunlockCommands::List => {
  166. let open = open_runs(config);
  167. if open.is_empty() {
  168. println!("no open runs (all ledgers closed)");
  169. return Ok(());
  170. }
  171. for (name, start, cluster) in open {
  172. println!(
  173. "{name}\tstarted {} UTC\tcluster {}",
  174. crate::stamp::fmt_ts(start),
  175. cluster.as_deref().unwrap_or("tmux"),
  176. );
  177. }
  178. }
  179. }
  180. Ok(())
  181. }
  182. /// Flip the recipe's `archived` flag in its own toml (comment-preserving).
  183. pub(crate) fn set_archived(recipe: &str, sources: &[String], archived: bool) -> Result<()> {
  184. let file = crate::config::recipe_file(recipe, sources)
  185. .ok_or_else(|| anyhow::anyhow!("recipe file not found for '{recipe}'"))?;
  186. let text = std::fs::read_to_string(&file)?;
  187. let mut doc: toml_edit::DocumentMut = text.parse()?;
  188. doc["archived"] = toml_edit::value(archived);
  189. std::fs::write(&file, doc.to_string())?;
  190. Ok(())
  191. }
  192. #[cfg(test)]
  193. mod tests {
  194. use super::*;
  195. fn dir(tag: &str) -> PathBuf {
  196. let d = std::env::temp_dir().join(format!("sm121-runlock-{}-{tag}", std::process::id()));
  197. std::fs::create_dir_all(d.join("r-lock")).unwrap();
  198. std::fs::write(
  199. d.join("r-lock/recipe.toml"),
  200. "engine = \"vllm\"\nname = \"T\"\n",
  201. )
  202. .unwrap();
  203. d
  204. }
  205. fn sources(d: &Path) -> Vec<String> {
  206. vec![d.to_string_lossy().to_string()]
  207. }
  208. #[test]
  209. fn start_stop_roundtrip_and_counts() {
  210. let d = dir("rt");
  211. let src = sources(&d);
  212. mark_start("r-lock", &src, Some("c1")).unwrap();
  213. let l = Lock::load(&d.join("r-lock"));
  214. assert_eq!(l.total, 1);
  215. assert!(l.running.is_some());
  216. assert_eq!(l.runs.len(), 1);
  217. assert_eq!(l.runs[0].cluster.as_deref(), Some("c1"));
  218. mark_stop("r-lock", &src).unwrap();
  219. let l = Lock::load(&d.join("r-lock"));
  220. assert!(l.running.is_none());
  221. assert_eq!(l.runs[0].end, Some(l.runs[0].start), "instant stop");
  222. assert_eq!(l.runs[0].uptime_secs, Some(0));
  223. // stop without a tracked run: no-op
  224. mark_stop("r-lock", &src).unwrap();
  225. assert_eq!(Lock::load(&d.join("r-lock")).total, 1);
  226. std::fs::remove_dir_all(&d).ok();
  227. }
  228. #[test]
  229. fn start_over_stale_run_closes_it() {
  230. let d = dir("stale");
  231. let src = sources(&d);
  232. mark_start("r-lock", &src, None).unwrap();
  233. // rewind the open run's start so the stale-close uptime is positive
  234. let p = d.join("r-lock/.lock");
  235. let mut l = Lock::load(&d.join("r-lock"));
  236. l.runs[0].start -= 100;
  237. l.running.as_mut().unwrap().start -= 100;
  238. std::fs::write(&p, serde_json::to_string(&l).unwrap()).unwrap();
  239. // a new serve without a stop in between: the stale run closes
  240. mark_start("r-lock", &src, Some("c2")).unwrap();
  241. let l = Lock::load(&d.join("r-lock"));
  242. assert_eq!(l.total, 2, "two launches");
  243. assert_eq!(l.runs.len(), 2);
  244. assert_eq!(l.runs[0].uptime_secs, Some(100), "stale closed");
  245. assert!(l.runs[1].end.is_none(), "new run open");
  246. assert_eq!(l.running.as_ref().unwrap().cluster.as_deref(), Some("c2"));
  247. std::fs::remove_dir_all(&d).ok();
  248. }
  249. #[test]
  250. fn history_is_capped_but_total_grows() {
  251. let d = dir("cap");
  252. let src = sources(&d);
  253. for _ in 0..HISTORY_CAP + 5 {
  254. mark_start("r-lock", &src, None).unwrap();
  255. mark_stop("r-lock", &src).unwrap();
  256. }
  257. let l = Lock::load(&d.join("r-lock"));
  258. assert_eq!(l.total, HISTORY_CAP as u64 + 5);
  259. assert_eq!(l.runs.len(), HISTORY_CAP);
  260. assert!(l.running.is_none());
  261. std::fs::remove_dir_all(&d).ok();
  262. }
  263. #[test]
  264. fn open_runs_lists_only_open_ledgers() {
  265. let d = dir("open");
  266. let src = sources(&d);
  267. mark_start("r-lock", &src, Some("c1")).unwrap();
  268. std::fs::create_dir_all(d.join("other")).unwrap();
  269. std::fs::write(
  270. d.join("other/recipe.toml"),
  271. "engine = \"vllm\"\nname = \"O\"\n",
  272. )
  273. .unwrap();
  274. mark_start("other", &src, Some("c1")).unwrap();
  275. mark_stop("other", &src).unwrap();
  276. let cfg: crate::config::Config =
  277. toml::from_str(&format!("recipe_sources = [\"{}\"]\n", d.display())).unwrap();
  278. let open = open_runs(&cfg);
  279. let mine = open.iter().find(|(n, _, _)| n == "r-lock");
  280. assert!(mine.is_some(), "open run not listed: {open:?}");
  281. assert_eq!(mine.unwrap().2.as_deref(), Some("c1"));
  282. assert!(
  283. open.iter().all(|(n, _, _)| n != "other"),
  284. "closed run must not be listed: {open:?}",
  285. );
  286. std::fs::remove_dir_all(&d).ok();
  287. }
  288. #[test]
  289. fn archived_flag_flips_round_trip() {
  290. let d = dir("arch");
  291. let src = sources(&d);
  292. // recipe with comments — toml_edit must preserve them
  293. let toml = d.join("r-lock/recipe.toml");
  294. std::fs::write(
  295. &toml,
  296. "# keep me\nengine = \"vllm\" # inline\nname = \"T\"\n",
  297. )
  298. .unwrap();
  299. set_archived("r-lock", &src, true).unwrap();
  300. let after = std::fs::read_to_string(&toml).unwrap();
  301. assert!(after.contains("archived = true"), "{after}");
  302. assert!(after.contains("# keep me"), "comment lost: {after}");
  303. assert!(
  304. after.contains("engine = \"vllm\" # inline"),
  305. "inline lost: {after}"
  306. );
  307. set_archived("r-lock", &src, false).unwrap();
  308. assert!(
  309. std::fs::read_to_string(&toml)
  310. .unwrap()
  311. .contains("archived = false")
  312. );
  313. std::fs::remove_dir_all(&d).ok();
  314. }
  315. }