gioser

tawasuyu/brahman

Rust 226TOML 67JSON 14HTML 12860 Definitions

⎇ main crates/modules/shuma/shuma-core/src/stats.rs Rust · 210 lines · 7.6 KB History Who touched what raw

refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG 550c98f27 · sergio ·

1//! Resource accounting por workspace.
2//!
3//! Dos fuentes:
4//! - **Per-proc** (`/proc/<pid>/status` + `stat`): suma RSS y CPU ticks de
5//!   los comandos vivos del workspace. Siempre disponible. Costo: O(N pids).
6//! - **Cgroup v2** (`memory.current`, `cpu.stat`): un read por workspace si
7//!   `SomaSpec.cgroup.path` está y es leíble. Más preciso (incluye descendants).
8//!
9//! Si ambos están disponibles, devolvemos el cgroup (más preciso) y dejamos
10//! el per-proc como `sample_via_proc`.
11
12use std::path::Path;
13use std::time::Instant;
14
15#[derive(Debug, Clone, Default)]
16pub struct WorkspaceStats {
17    pub commands_alive: u32,
18    pub commands_total: u32,
19    /// RSS sumado en bytes. `None` si no se pudo medir.
20    pub rss_bytes: Option<u64>,
21    /// High-water mark de RSS (peak alguna vez observado). Cgroup v2:
22    /// `memory.peak` (≥6.5). Per-proc: suma de `VmHWM` de cada pid.
23    pub rss_peak_bytes: Option<u64>,
24    /// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir.
25    pub cpu_usec: Option<u64>,
26    /// %CPU instantáneo derivado entre dos samples consecutivos. `None`
27    /// en el primer sample (no hay baseline). `100.0` = 1 core saturado.
28    /// `400.0` con 4 cores activos = la máquina al 100%.
29    pub cpu_percent: Option<f32>,
30    /// Cores online detectados (sysconf `_SC_NPROCESSORS_ONLN`). Útil
31    /// para normalizar `cpu_percent / cpu_cores` → 0..100 absoluto.
32    pub cpu_cores: u32,
33    /// Fuente del dato: "proc" | "cgroup" | "mixed".
34    pub source: String,
35    /// Wall-clock uptime del workspace en milisegundos.
36    pub uptime_ms: u64,
37}
38
39impl WorkspaceStats {
40    /// CPU% normalizado al 100% total de la máquina (no por core).
41    /// Útil para comparar workspaces independiente del paralelismo.
42    pub fn cpu_percent_total(&self) -> Option<f32> {
43        self.cpu_percent
44            .map(|p| if self.cpu_cores == 0 { p } else { p / self.cpu_cores as f32 })
45    }
46}
47
48/// Reporte de quotas: comparación entre el accounting real y los
49/// `rlimits` declarados en `SomaSpec`. NO hace enforcement automático
50/// en v1 — sólo accounting + reporting. El caller decide qué hacer.
51#[derive(Debug, Clone, Default)]
52pub struct QuotaReport {
53    /// Límite de memoria declarado (bytes). None = sin límite.
54    pub mem_limit: Option<u64>,
55    /// Límite de procesos declarado.
56    pub nproc_limit: Option<u32>,
57    /// Lista de violaciones detectadas (strings humano-legibles).
58    /// Empty = todo dentro de quota.
59    pub breaches: Vec<String>,
60}
61
62/// Detecta cores online runtime. Cacheado vía OnceLock — el valor no
63/// cambia salvo hotplug, que es raro y aceptamos sample stale.
64fn online_cores() -> u32 {
65    static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
66    *CACHED.get_or_init(|| {
67        let n = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
68        if n > 0 { n as u32 } else { 1 }
69    })
70}
71
72/// Mide stats para un set de PIDs vivos + un path de cgroup opcional.
73pub fn measure(
74    alive_pids: &[i32],
75    cgroup_path: Option<&Path>,
76    workspace_started: Instant,
77) -> WorkspaceStats {
78    let mut rss_proc: u64 = 0;
79    let mut rss_peak_proc: u64 = 0;
80    let mut cpu_proc: u64 = 0;
81    let mut proc_ok = false;
82    for &pid in alive_pids {
83        if let Some((rss, peak, cpu)) = read_proc_pid(pid) {
84            rss_proc += rss;
85            rss_peak_proc += peak;
86            cpu_proc += cpu;
87            proc_ok = true;
88        }
89    }
90
91    let cgroup = cgroup_path.and_then(read_cgroup_stats);
92
93    let (rss, rss_peak, cpu, source) = match (cgroup, proc_ok) {
94        (Some(cg), _) => (Some(cg.rss), cg.rss_peak, Some(cg.cpu_usec), "cgroup".to_string()),
95        (None, true) => (
96            Some(rss_proc),
97            Some(rss_peak_proc),
98            Some(cpu_proc),
99            "proc".to_string(),
100        ),
101        (None, false) => (None, None, None, "none".to_string()),
102    };
103
104    WorkspaceStats {
105        commands_alive: alive_pids.len() as u32,
106        commands_total: 0,
107        rss_bytes: rss,
108        rss_peak_bytes: rss_peak,
109        cpu_usec: cpu,
110        cpu_percent: None, // El caller lo rellena con el diff vs prev sample.
111        cpu_cores: online_cores(),
112        source,
113        uptime_ms: workspace_started.elapsed().as_millis() as u64,
114    }
115}
116
117struct CgroupStats {
118    rss: u64,
119    rss_peak: Option<u64>,
120    cpu_usec: u64,
121}
122
123/// Lee `(rss_bytes, rss_peak_bytes, cpu_usec)` de `/proc/<pid>/`. None si el proc desapareció.
124fn read_proc_pid(pid: i32) -> Option<(u64, u64, u64)> {
125    let (rss_kb, hwm_kb) = {
126        let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
127        let mut rss = 0u64;
128        let mut hwm = 0u64;
129        for l in status.lines() {
130            if let Some(rest) = l.strip_prefix("VmRSS:") {
131                rss = rest
132                    .trim()
133                    .split_whitespace()
134                    .next()
135                    .and_then(|s| s.parse().ok())
136                    .unwrap_or(0);
137            } else if let Some(rest) = l.strip_prefix("VmHWM:") {
138                hwm = rest
139                    .trim()
140                    .split_whitespace()
141                    .next()
142                    .and_then(|s| s.parse().ok())
143                    .unwrap_or(0);
144            }
145        }
146        (rss, hwm)
147    };
148    let cpu_usec = {
149        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
150        // formato: pid (comm) state ppid pgrp ... utime stime cutime cstime
151        // Cuidado: comm puede tener espacios y paréntesis. Buscamos la última `)`.
152        let end_comm = stat.rfind(')')?;
153        let after = &stat[end_comm + 1..];
154        let fields: Vec<&str> = after.split_whitespace().collect();
155        // Tras `)`, índice 0 = state, índice 11 = utime, 12 = stime.
156        let utime = fields.get(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
157        let stime = fields.get(12).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
158        let ticks = utime + stime;
159        // Convertimos ticks → microsegundos. SC_CLK_TCK típicamente 100.
160        let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }.max(1) as u64;
161        ticks * 1_000_000 / clk_tck
162    };
163    Some((rss_kb * 1024, hwm_kb * 1024, cpu_usec))
164}
165
166/// Lee `CgroupStats` del cgroup. None si no existe o no es leíble.
167/// `memory.peak` requiere kernel ≥6.5; si falta, `rss_peak` queda None.
168fn read_cgroup_stats(cgroup_path: &Path) -> Option<CgroupStats> {
169    let mem = std::fs::read_to_string(cgroup_path.join("memory.current"))
170        .ok()
171        .and_then(|s| s.trim().parse::<u64>().ok())?;
172    let cpu_stat = std::fs::read_to_string(cgroup_path.join("cpu.stat")).ok()?;
173    let cpu_usec = cpu_stat
174        .lines()
175        .find_map(|l| l.strip_prefix("usage_usec"))
176        .and_then(|s| s.split_whitespace().next())
177        .and_then(|s| s.parse::<u64>().ok())
178        .unwrap_or(0);
179    let peak = std::fs::read_to_string(cgroup_path.join("memory.peak"))
180        .ok()
181        .and_then(|s| s.trim().parse::<u64>().ok());
182    Some(CgroupStats {
183        rss: mem,
184        rss_peak: peak,
185        cpu_usec,
186    })
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn measure_with_no_pids_returns_zero() {
195        let stats = measure(&[], None, Instant::now());
196        assert_eq!(stats.commands_alive, 0);
197        assert_eq!(stats.rss_bytes, None);
198        assert_eq!(stats.source, "none");
199    }
200
201    #[test]
202    fn measure_self_pid_returns_data() {
203        let me = std::process::id() as i32;
204        let stats = measure(&[me], None, Instant::now());
205        assert_eq!(stats.commands_alive, 1);
206        // Nuestro propio RSS debería ser > 0.
207        assert!(stats.rss_bytes.unwrap_or(0) > 0);
208        assert_eq!(stats.source, "proc");
209    }
210}