runlock.rs 13 KB

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