cache.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. //! Shared Hugging Face cache management: `sm121 cache status|doctor`.
  2. //!
  3. //! The cache lives on the NAS and is reached over SSH through the cluster (an
  4. //! add-on on a Pi has no NAS mount, so this is the only path that works
  5. //! everywhere). A node that holds the mount runs the actual inspection —
  6. //! `[cache].node` pins it, otherwise the rank-0 node of `--cluster` is used.
  7. //!
  8. //! Read-only here: `status` reports what is cached and how much room is left,
  9. //! `doctor` reports whether the tooling (`hfrs`, `uvx`, `flare`) that the
  10. //! update job needs is actually present. Destructive steps (`update`, `prune`)
  11. //! are separate subcommands so they never run by accident.
  12. use anyhow::Result;
  13. use serde_json::json;
  14. use crate::config::Config;
  15. /// Env/CLI knobs for the cache commands.
  16. pub struct CacheOpts {
  17. pub cluster: Option<String>,
  18. pub json: bool,
  19. pub dry_run: bool,
  20. }
  21. /// The node that can see the cache: `[cache].node`, else rank 0 of the chosen
  22. /// cluster, else rank 0 of the first cluster.
  23. fn cache_node(config: &Config, cluster: Option<&str>) -> Result<crate::config::Node> {
  24. if let Some(name) = config.cache.as_ref().and_then(|c| c.node.clone()) {
  25. return config
  26. .clusters
  27. .iter()
  28. .flat_map(|c| c.nodes.iter())
  29. .find(|n| n.name == name)
  30. .cloned()
  31. .ok_or_else(|| anyhow::anyhow!("[cache].node '{name}' is not a configured node"));
  32. }
  33. let cl = match cluster {
  34. Some(name) => crate::config::select_cluster_by_name(config, name)?,
  35. None => config
  36. .clusters
  37. .first()
  38. .cloned()
  39. .ok_or_else(|| anyhow::anyhow!("no clusters configured"))?,
  40. };
  41. cl.nodes
  42. .iter()
  43. .find(|n| n.rank == 0)
  44. .or_else(|| cl.nodes.first())
  45. .cloned()
  46. .ok_or_else(|| anyhow::anyhow!("cluster '{}' has no nodes", cl.name))
  47. }
  48. /// HF cache root (config `[cache].hf_home`, else the ssh user's default).
  49. pub fn hf_home(config: &Config) -> String {
  50. config
  51. .cache
  52. .as_ref()
  53. .and_then(|c| c.hf_home.clone())
  54. .unwrap_or_else(|| config.resolved_hf_cache_dir().display().to_string())
  55. }
  56. /// CLI dispatch (`sm121 cache <subcommand>`).
  57. pub async fn dispatch(
  58. config: &Config,
  59. cmd: &crate::cli::CacheCommands,
  60. dry_run: bool,
  61. ) -> Result<()> {
  62. use crate::cli::CacheCommands as C;
  63. let (cluster, json) = match cmd {
  64. C::Status { cluster, json } | C::Doctor { cluster, json } => (cluster.clone(), *json),
  65. C::Update { cluster, json, .. } => (cluster.clone(), *json),
  66. };
  67. let opts = CacheOpts {
  68. cluster,
  69. json,
  70. dry_run,
  71. };
  72. match cmd {
  73. C::Status { .. } => status(config, &opts).await,
  74. C::Doctor { .. } => doctor(config, &opts).await,
  75. C::Update { repos, apply, .. } => update(config, &opts, repos, *apply).await,
  76. }
  77. }
  78. /// One cached repo (name + size), shared by the CLI table and the API.
  79. #[derive(Clone, serde::Serialize)]
  80. pub struct CacheRepo {
  81. pub repo: String,
  82. pub bytes: u64,
  83. pub gib: f64,
  84. }
  85. /// What the cache looks like right now: the node it was read from, the mount
  86. /// point, the filesystem line and every repo with its size.
  87. #[derive(Clone, serde::Serialize)]
  88. pub struct CacheStatus {
  89. pub node: String,
  90. pub hf_home: String,
  91. pub df: String,
  92. pub repos: Vec<CacheRepo>,
  93. pub total_gib: f64,
  94. }
  95. /// Gather the cache status (`status` prints it, the dashboard serves it).
  96. pub async fn collect(config: &Config, opts: &CacheOpts) -> Result<CacheStatus> {
  97. let node = cache_node(config, opts.cluster.as_deref())?;
  98. let home = hf_home(config);
  99. let root = crate::docker::shq(&home);
  100. let cmd = format!(
  101. "df -BG --output=size,used,avail {root} 2>/dev/null | tail -1; \
  102. du -s --block-size=1 {root}/hub/*--* 2>/dev/null | awk '{{print $1, $2}}'"
  103. );
  104. let out = crate::ssh::run_ssh(config, &node, cmd, opts.dry_run).await?;
  105. let mut df = String::new();
  106. let mut repos: Vec<CacheRepo> = Vec::new();
  107. for line in out.lines() {
  108. let toks: Vec<&str> = line.split_whitespace().collect();
  109. match toks.as_slice() {
  110. // `df -BG`: "931G 700G 231G" — size/used/avail, no path column
  111. [size, used, avail] if size.parse::<u64>().is_err() => {
  112. df = format!("{size} total, {used} used, {avail} avail");
  113. }
  114. // `du -s --block-size=1`: "<bytes> <path>"
  115. [bytes, path] => {
  116. if let Ok(b) = bytes.parse::<u64>() {
  117. let name = path.rsplit('/').next().unwrap_or(path).to_string();
  118. repos.push(CacheRepo {
  119. repo: name,
  120. bytes: b,
  121. gib: b as f64 / (1u64 << 30) as f64,
  122. });
  123. }
  124. }
  125. _ => {}
  126. }
  127. }
  128. repos.sort_by_key(|r| std::cmp::Reverse(r.bytes));
  129. let total_gib = repos.iter().map(|r| r.gib).sum();
  130. Ok(CacheStatus {
  131. node: node.name,
  132. hf_home: home,
  133. df,
  134. repos,
  135. total_gib,
  136. })
  137. }
  138. /// `sm121 cache status` — repos in the cache, their sizes, and the room left.
  139. pub async fn status(config: &Config, opts: &CacheOpts) -> Result<()> {
  140. let st = collect(config, opts).await?;
  141. if opts.json {
  142. println!("{}", serde_json::to_string_pretty(&st)?);
  143. return Ok(());
  144. }
  145. println!("cache: {} (on {}, df {})", st.hf_home, st.node, st.df);
  146. println!("{:<58} {:>9}", "repo", "GiB");
  147. for r in &st.repos {
  148. println!("{:<58} {:>9.1}", r.repo, r.gib);
  149. }
  150. println!(
  151. "{:<58} {:>9.1}",
  152. format!("— {} repos", st.repos.len()),
  153. st.total_gib
  154. );
  155. Ok(())
  156. }
  157. /// `sm121 cache doctor` — is the tooling the maintenance job needs present?
  158. pub async fn doctor(config: &Config, opts: &CacheOpts) -> Result<()> {
  159. let node = cache_node(config, opts.cluster.as_deref())?;
  160. let home = hf_home(config);
  161. let cmd = format!(
  162. // A non-login shell has a bare PATH: also look where the tools land.
  163. "PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:$PATH\"; \
  164. for t in hfrs uvx hf; do printf '%s=%s\\n' \"$t\" \"$(command -v $t || echo MISSING)\"; done; \
  165. if [ -x \"$HOME/flare-bin/flare\" ]; then echo \"flare=$HOME/flare-bin/flare\"; \
  166. elif command -v flare >/dev/null; then echo \"flare=$(command -v flare)\"; else echo flare=MISSING; fi; \
  167. [ -d {root} ] && echo \"hf_home=ok\" || echo \"hf_home=MISSING\"; \
  168. touch {root}/.sm121-write-test 2>/dev/null && echo write=ok && rm -f {root}/.sm121-write-test || echo write=FAILED; \
  169. df -BG --output=avail {root} 2>/dev/null | tail -1 | sed 's/^/free=/'",
  170. root = crate::docker::shq(&home)
  171. );
  172. let out = crate::ssh::run_ssh(config, &node, cmd, opts.dry_run).await?;
  173. if opts.json {
  174. let map: serde_json::Map<String, serde_json::Value> = out
  175. .lines()
  176. .filter_map(|l| l.split_once('='))
  177. .map(|(k, v)| (k.trim().to_string(), json!(v.trim())))
  178. .collect();
  179. println!(
  180. "{}",
  181. serde_json::to_string_pretty(&json!({"node": node.name, "checks": map}))?
  182. );
  183. return Ok(());
  184. }
  185. println!(
  186. "cache doctor on {} ({}), keep_revs={:?}:",
  187. node.name,
  188. home,
  189. config.cache.as_ref().and_then(|c| c.keep_revs)
  190. );
  191. for line in out.lines() {
  192. let ok = !line.contains("MISSING") && !line.contains("FAILED");
  193. println!(" {} {}", if ok { "✓" } else { "✗" }, line);
  194. }
  195. Ok(())
  196. }
  197. /// Expected size of `repo@rev` from the HF API (bytes), for the progress line.
  198. /// Sums the file sizes; a failure only costs the percentage (progress falls
  199. /// back to raw GB), so it never blocks the update.
  200. async fn expected_bytes(repo: &str, rev: &str) -> Option<u64> {
  201. let url = format!("https://huggingface.co/api/models/{repo}/tree/{rev}?recursive=1");
  202. let client = reqwest::Client::builder()
  203. .timeout(std::time::Duration::from_secs(20))
  204. .build()
  205. .ok()?;
  206. let body: serde_json::Value = client.get(url).send().await.ok()?.json().await.ok()?;
  207. let mut total = 0u64;
  208. for f in body.as_array()? {
  209. if f.get("type").and_then(|t| t.as_str()) != Some("file") {
  210. continue;
  211. }
  212. let size = f
  213. .get("size")
  214. .and_then(serde_json::Value::as_u64)
  215. .unwrap_or(0);
  216. let path = f.get("path").and_then(|p| p.as_str()).unwrap_or("");
  217. // .gitattributes etc. are noise, not weights
  218. if !path.starts_with('.') {
  219. total += size;
  220. }
  221. }
  222. (total > 0).then_some(total)
  223. }
  224. /// Live state of a running `cache update` (the dashboard renders it, the CLI
  225. /// renders the same fields as a progress line).
  226. #[derive(Clone, Default, serde::Serialize)]
  227. pub struct UpdateProgress {
  228. pub running: bool,
  229. pub stage: String,
  230. pub repo: Option<String>,
  231. pub index: usize,
  232. pub total: usize,
  233. pub bytes: u64,
  234. pub expected: Option<u64>,
  235. pub percent: Option<f64>,
  236. pub failed: Vec<String>,
  237. pub node: String,
  238. pub message: String,
  239. }
  240. /// `sm121 cache update` — refresh every cached repo to its refs (or the ones
  241. /// selected with `--repo`), the same loop `hf-cache-maintain.sh` runs, but over
  242. /// SSH so the machine driving it needs no mount.
  243. ///
  244. /// Progress: the remote shell samples the repo's blob directory every 10 s
  245. /// while `hfrs` runs and prints `PROGRESS <bytes>`; the launcher turns that into
  246. /// a percentage of the API-reported size. One SSH for the whole download, and
  247. /// the sampler touches only a directory listing, so the download itself is not
  248. /// slowed.
  249. pub async fn update(
  250. config: &Config,
  251. opts: &CacheOpts,
  252. repos: &[String],
  253. apply: bool,
  254. ) -> Result<()> {
  255. let mut last = String::new();
  256. update_with(config, opts, repos, apply, |p| {
  257. if p.stage == "download" {
  258. let text = p.message.clone();
  259. if text != last {
  260. use std::io::Write;
  261. print!("\r {text:<70}");
  262. let _ = std::io::stdout().flush();
  263. last = text;
  264. }
  265. } else if p.stage == "repo-done" {
  266. if !last.is_empty() {
  267. println!();
  268. last.clear();
  269. }
  270. println!(" {}", p.message);
  271. } else if p.stage == "dry-run" {
  272. println!(" {}", p.message);
  273. }
  274. })
  275. .await
  276. }
  277. /// Same as `update`, but reports every step to `report` — the dashboard's
  278. /// background job and the CLI progress line are two renderings of one loop.
  279. pub async fn update_with<F: FnMut(&UpdateProgress)>(
  280. config: &Config,
  281. opts: &CacheOpts,
  282. repos: &[String],
  283. apply: bool,
  284. mut report: F,
  285. ) -> Result<()> {
  286. let node = cache_node(config, opts.cluster.as_deref())?;
  287. let home = hf_home(config);
  288. let mut prog = UpdateProgress {
  289. running: true,
  290. stage: "listing".into(),
  291. node: node.name.clone(),
  292. message: "listing cached repos".into(),
  293. ..Default::default()
  294. };
  295. report(&prog);
  296. let list_cmd = format!(
  297. "ls -d {root}/hub/*--* 2>/dev/null",
  298. root = crate::docker::shq(&home)
  299. );
  300. let listing = crate::ssh::run_ssh(config, &node, list_cmd, opts.dry_run).await?;
  301. let mut targets: Vec<(String, String)> = Vec::new();
  302. for dir in listing.lines() {
  303. let Some(base) = dir.rsplit('/').next() else {
  304. continue;
  305. };
  306. let Some((kind, rest)) = base.split_once("--") else {
  307. continue;
  308. };
  309. if kind != "models" {
  310. continue;
  311. }
  312. let repo = rest.replacen("--", "/", 1);
  313. if !repos.is_empty() && !repos.iter().any(|r| r == &repo) {
  314. continue;
  315. }
  316. targets.push((repo, dir.to_string()));
  317. }
  318. if targets.is_empty() {
  319. prog.running = false;
  320. prog.stage = "done".into();
  321. prog.message = format!("nothing to update on {}", node.name);
  322. report(&prog);
  323. return Ok(());
  324. }
  325. prog.total = targets.len();
  326. let mut failures = 0usize;
  327. for (i, (repo, dir)) in targets.iter().enumerate() {
  328. let rev = "main";
  329. prog.index = i + 1;
  330. prog.repo = Some(repo.clone());
  331. prog.bytes = 0;
  332. prog.percent = None;
  333. prog.expected = expected_bytes(repo, rev).await;
  334. prog.stage = "repo-start".into();
  335. prog.message = format!("{repo} @ {rev}");
  336. report(&prog);
  337. if !apply || opts.dry_run {
  338. prog.stage = "dry-run".into();
  339. prog.message = format!("[dry-run] hfrs download {repo} --revision {rev}");
  340. report(&prog);
  341. continue;
  342. }
  343. let quoted_dir = crate::docker::shq(dir);
  344. let cmd = format!(
  345. "export PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:$PATH\"; \
  346. ( hfrs download {repo} --type model --revision {rev} \
  347. --disable-progress-bars --quiet >/dev/null 2>&1 & p=$!; \
  348. while kill -0 $p 2>/dev/null; do \
  349. b=$(du -sb {quoted_dir}/blobs 2>/dev/null | cut -f1); \
  350. echo \"PROGRESS ${{b:-0}}\"; sleep 10; done; wait $p; echo \"EXIT $?\" )"
  351. );
  352. prog.stage = "download".into();
  353. report(&prog);
  354. let out = crate::ssh::run_ssh(config, &node, cmd, false).await?;
  355. let mut ok = true;
  356. for line in out.lines() {
  357. if let Some(bytes) = line.strip_prefix("PROGRESS ") {
  358. prog.bytes = bytes.trim().parse().unwrap_or(0);
  359. prog.percent = prog
  360. .expected
  361. .filter(|t| *t > 1_000_000)
  362. .map(|t| (prog.bytes as f64 / t as f64 * 100.0).min(100.0));
  363. prog.message = match prog.expected {
  364. Some(t) if t > 1_000_000 => format!(
  365. "{repo} {:.1}% ({:.1} / {:.1} GB)",
  366. prog.percent.unwrap_or(0.0),
  367. prog.bytes as f64 / 1e9,
  368. t as f64 / 1e9
  369. ),
  370. _ => format!("{repo} {:.1} GB", prog.bytes as f64 / 1e9),
  371. };
  372. report(&prog);
  373. } else if let Some(code) = line.strip_prefix("EXIT ") {
  374. ok = code.trim() == "0";
  375. }
  376. }
  377. prog.stage = "repo-done".into();
  378. if ok {
  379. prog.message = format!("[ok] {repo} @ {rev}");
  380. } else {
  381. prog.message = format!("[FAIL] {repo} @ {rev}");
  382. prog.failed.push(repo.clone());
  383. failures += 1;
  384. }
  385. report(&prog);
  386. }
  387. prog.running = false;
  388. prog.stage = "done".into();
  389. prog.repo = None;
  390. prog.message = if failures > 0 {
  391. format!("{failures} repo(s) failed to update on {}", node.name)
  392. } else {
  393. format!("updated {} repo(s) on {}", targets.len(), node.name)
  394. };
  395. report(&prog);
  396. if failures > 0 {
  397. anyhow::bail!("{}", prog.message);
  398. }
  399. Ok(())
  400. }