Skip to main content

rch_common/
disk_pressure_report.rs

1//! Per-worker disk / inode / target-root / cargo-home / log pressure report for
2//! `rch doctor` and status (bd-session-history-remediation-ocv9i.11.4).
3//!
4//! Session history showed two recurring confusions operators hit under disk
5//! pressure:
6//!
7//! 1. **No single place to see pressure.** Disk %, inodes, the cargo home, the
8//!    target root, the sync root, and log growth were scattered or absent, so a
9//!    capacity collapse was hard to attribute.
10//! 2. **Worker filesystem errors misread as product compile failures.** A
11//!    `No space left on device` during an offloaded build is a *worker /
12//!    environment* failure, not the project failing to compile — yet it
13//!    surfaced as a build error and sent agents debugging their own code.
14//!
15//! This module provides the pure, testable core for both:
16//! [`assess_root`] / [`WorkerDiskPressureReport`] classify each filesystem
17//! dimension into a [`PressureLevel`] (with `Unknown` for missing metrics or a
18//! telemetry gap), and [`classify_exec_failure`] separates a worker/environment
19//! disk failure from a genuine product compile failure — defaulting an ENOSPC
20//! to worker/environment *unless real compiler diagnostics prove otherwise*.
21//!
22//! Rendering this in the `rch doctor` CLI and feeding it from live worker facts
23//! is the surface/daemon wiring; this module is the shared assessment contract
24//! the doctor view, status, metrics, and dashboards all key off.
25
26use serde::{Deserialize, Serialize};
27
28use crate::telemetry_freshness::FreshnessVerdict;
29use crate::worker_facts::DiskRootFacts;
30
31/// Pressure severity for one filesystem dimension.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum PressureLevel {
35    /// Comfortable headroom.
36    Ok,
37    /// Approaching a threshold — worth attention.
38    Warning,
39    /// At/over the critical threshold — action needed.
40    Critical,
41    /// Cannot assess (metric absent, or telemetry too stale to trust).
42    Unknown,
43}
44
45impl PressureLevel {
46    /// Stable lowercase token.
47    #[must_use]
48    pub fn as_str(self) -> &'static str {
49        match self {
50            PressureLevel::Ok => "ok",
51            PressureLevel::Warning => "warning",
52            PressureLevel::Critical => "critical",
53            PressureLevel::Unknown => "unknown",
54        }
55    }
56
57    /// Severity rank for aggregation. `Unknown` outranks `Ok` (a missing metric
58    /// is more concerning than a healthy one) but not a known `Warning`/`Critical`.
59    #[must_use]
60    const fn rank(self) -> u8 {
61        match self {
62            PressureLevel::Ok => 0,
63            PressureLevel::Unknown => 1,
64            PressureLevel::Warning => 2,
65            PressureLevel::Critical => 3,
66        }
67    }
68
69    /// The worse (higher-rank) of two levels.
70    #[must_use]
71    pub fn worse(self, other: PressureLevel) -> PressureLevel {
72        if other.rank() > self.rank() {
73            other
74        } else {
75            self
76        }
77    }
78}
79
80/// Which RCH-relevant filesystem a root represents.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum DiskRootKind {
84    /// The pooled/per-job Rust target root.
85    TargetRoot,
86    /// The cargo home (registry/cache).
87    CargoHome,
88    /// The upload/sync staging root.
89    SyncRoot,
90    /// RCH log directory.
91    Log,
92    /// The temp root builds land under.
93    TempRoot,
94    /// Any other tracked root.
95    Other,
96}
97
98impl DiskRootKind {
99    /// Stable lowercase token.
100    #[must_use]
101    pub fn as_str(self) -> &'static str {
102        match self {
103            DiskRootKind::TargetRoot => "target_root",
104            DiskRootKind::CargoHome => "cargo_home",
105            DiskRootKind::SyncRoot => "sync_root",
106            DiskRootKind::Log => "log",
107            DiskRootKind::TempRoot => "temp_root",
108            DiskRootKind::Other => "other",
109        }
110    }
111}
112
113/// Thresholds for classifying pressure. Byte thresholds are percent-of-total
114/// available; inode thresholds are absolute available counts.
115#[derive(Debug, Clone, Copy, PartialEq)]
116pub struct PressureThresholds {
117    pub warning_avail_pct: f64,
118    pub critical_avail_pct: f64,
119    pub warning_avail_inodes: u64,
120    pub critical_avail_inodes: u64,
121}
122
123impl Default for PressureThresholds {
124    fn default() -> Self {
125        Self {
126            warning_avail_pct: 15.0,
127            critical_avail_pct: 5.0,
128            warning_avail_inodes: 100_000,
129            critical_avail_inodes: 10_000,
130        }
131    }
132}
133
134/// Assessed pressure for one filesystem root.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct RootPressure {
137    pub kind: DiskRootKind,
138    pub path: String,
139    /// Byte-capacity pressure.
140    pub bytes: PressureLevel,
141    /// Inode-capacity pressure.
142    pub inodes: PressureLevel,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub available_bytes: Option<u64>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub total_bytes: Option<u64>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub available_inodes: Option<u64>,
149}
150
151impl RootPressure {
152    /// The worse of this root's byte and inode pressure.
153    #[must_use]
154    pub fn worst(&self) -> PressureLevel {
155        self.bytes.worse(self.inodes)
156    }
157}
158
159/// Assess one root. `facts == None` (the metric was never collected) yields
160/// `Unknown` for both dimensions, so "missing disk metrics" is distinct from a
161/// healthy `Ok`. A zero `total_bytes` is also `Unknown` (df gave no usable
162/// figure); zero available inodes is treated as exhaustion (`Critical`).
163#[must_use]
164pub fn assess_root(
165    kind: DiskRootKind,
166    facts: Option<&DiskRootFacts>,
167    thresholds: &PressureThresholds,
168) -> RootPressure {
169    let Some(facts) = facts else {
170        return RootPressure {
171            kind,
172            path: String::new(),
173            bytes: PressureLevel::Unknown,
174            inodes: PressureLevel::Unknown,
175            available_bytes: None,
176            total_bytes: None,
177            available_inodes: None,
178        };
179    };
180
181    let bytes = if facts.total_bytes == 0 {
182        PressureLevel::Unknown
183    } else {
184        // available as a percent of total.
185        let pct = (facts.available_bytes as f64 / facts.total_bytes as f64) * 100.0;
186        if pct <= thresholds.critical_avail_pct {
187            PressureLevel::Critical
188        } else if pct <= thresholds.warning_avail_pct {
189            PressureLevel::Warning
190        } else {
191            PressureLevel::Ok
192        }
193    };
194
195    let inodes = if facts.available_inodes < thresholds.critical_avail_inodes {
196        PressureLevel::Critical
197    } else if facts.available_inodes < thresholds.warning_avail_inodes {
198        PressureLevel::Warning
199    } else {
200        PressureLevel::Ok
201    };
202
203    RootPressure {
204        kind,
205        path: facts.path.clone(),
206        bytes,
207        inodes,
208        available_bytes: Some(facts.available_bytes),
209        total_bytes: Some(facts.total_bytes),
210        available_inodes: Some(facts.available_inodes),
211    }
212}
213
214/// A per-worker disk-pressure report for `rch doctor`/status.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct WorkerDiskPressureReport {
217    pub worker_id: String,
218    /// Telemetry freshness for the worker's metrics; a stale/unknown verdict
219    /// caps confidence in the levels below.
220    pub telemetry: FreshnessVerdict,
221    pub roots: Vec<RootPressure>,
222}
223
224impl WorkerDiskPressureReport {
225    /// Worst pressure across all roots. When telemetry is not usable and nothing
226    /// is already `Critical`, the worst is at least `Unknown` — the figures
227    /// can't be trusted, so the report must not read as a confident `Ok`.
228    #[must_use]
229    pub fn worst(&self) -> PressureLevel {
230        let mut worst = self
231            .roots
232            .iter()
233            .fold(PressureLevel::Ok, |acc, r| acc.worse(r.worst()));
234        if !self.telemetry.is_usable() && worst != PressureLevel::Critical {
235            worst = worst.worse(PressureLevel::Unknown);
236        }
237        worst
238    }
239
240    /// Whether any root is at critical pressure.
241    #[must_use]
242    pub fn has_critical(&self) -> bool {
243        self.roots
244            .iter()
245            .any(|r| r.worst() == PressureLevel::Critical)
246    }
247
248    /// Human-readable summary line.
249    #[must_use]
250    pub fn render(&self) -> String {
251        format!(
252            "worker {} disk pressure: {} (telemetry={}, {} root(s))",
253            self.worker_id,
254            self.worst().as_str(),
255            if self.telemetry.is_usable() {
256                "ok"
257            } else {
258                "stale"
259            },
260            self.roots.len(),
261        )
262    }
263}
264
265/// Classification of a failed offloaded execution.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum ExecFailureClass {
269    /// A worker/environment failure (e.g. disk full) — NOT the project's code.
270    WorkerEnvironment,
271    /// A genuine product compile failure proven by compiler diagnostics.
272    ProductCompile,
273    /// Neither signal present — cannot attribute.
274    Indeterminate,
275}
276
277impl ExecFailureClass {
278    /// Stable lowercase token.
279    #[must_use]
280    pub fn as_str(self) -> &'static str {
281        match self {
282            ExecFailureClass::WorkerEnvironment => "worker_environment",
283            ExecFailureClass::ProductCompile => "product_compile",
284            ExecFailureClass::Indeterminate => "indeterminate",
285        }
286    }
287}
288
289/// Whether `stderr` mentions an out-of-space / quota condition.
290#[must_use]
291pub fn mentions_disk_full(stderr: &str) -> bool {
292    let s = stderr.to_ascii_lowercase();
293    s.contains("no space left on device")
294        || s.contains("enospc")
295        || s.contains("disk quota exceeded")
296}
297
298/// Whether `stderr` carries a genuine rustc/cargo *compiler* diagnostic — a
299/// coded error (`error[E0001]`), the abort tail, or "could not compile … due
300/// to". Deliberately NOT a bare `error:` line, because a disk-write failure
301/// prints `error: failed to write … No space left on device`, which is an
302/// environment failure, not a compile error.
303#[must_use]
304pub fn mentions_compiler_diagnostic(stderr: &str) -> bool {
305    let s = stderr.to_ascii_lowercase();
306    // `error[E0XXX]` coded diagnostic.
307    if let Some(idx) = s.find("error[e") {
308        let rest = &s[idx + "error[e".len()..];
309        if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) {
310            return true;
311        }
312    }
313    s.contains("aborting due to") || s.contains("could not compile")
314}
315
316/// Classify a failed execution's stderr. An out-of-space condition defaults to
317/// [`ExecFailureClass::WorkerEnvironment`] unless real compiler diagnostics
318/// prove the build genuinely failed to compile.
319#[must_use]
320pub fn classify_exec_failure(stderr: &str) -> ExecFailureClass {
321    let disk_full = mentions_disk_full(stderr);
322    let compiler = mentions_compiler_diagnostic(stderr);
323    match (disk_full, compiler) {
324        // ENOSPC + real compiler diagnostics: the diagnostics prove otherwise.
325        (true, true) => ExecFailureClass::ProductCompile,
326        // ENOSPC with no compiler diagnostics: worker/environment failure.
327        (true, false) => ExecFailureClass::WorkerEnvironment,
328        // No disk issue, real diagnostics: a product compile failure.
329        (false, true) => ExecFailureClass::ProductCompile,
330        // Neither signal: cannot attribute.
331        (false, false) => ExecFailureClass::Indeterminate,
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn facts(path: &str, total: u64, avail: u64, inodes: u64) -> DiskRootFacts {
340        DiskRootFacts {
341            path: path.to_string(),
342            total_bytes: total,
343            available_bytes: avail,
344            available_inodes: inodes,
345        }
346    }
347
348    fn th() -> PressureThresholds {
349        PressureThresholds::default()
350    }
351
352    // --- Pressure levels per dimension --------------------------------------
353
354    #[test]
355    fn critical_pressure_when_bytes_nearly_full() {
356        // 2% available <= 5% critical threshold.
357        let r = assess_root(
358            DiskRootKind::TargetRoot,
359            Some(&facts("/tmp/rch", 100_000_000, 2_000_000, 5_000_000)),
360            &th(),
361        );
362        assert_eq!(r.bytes, PressureLevel::Critical);
363        assert_eq!(r.worst(), PressureLevel::Critical);
364    }
365
366    #[test]
367    fn warning_pressure_in_the_warning_band() {
368        // 10% available: below 15% warning, above 5% critical.
369        let r = assess_root(
370            DiskRootKind::CargoHome,
371            Some(&facts("/home/rch/.cargo", 100, 10, 5_000_000)),
372            &th(),
373        );
374        assert_eq!(r.bytes, PressureLevel::Warning);
375    }
376
377    #[test]
378    fn ok_pressure_with_headroom() {
379        let r = assess_root(
380            DiskRootKind::SyncRoot,
381            Some(&facts("/tmp/rch/sync", 100, 80, 5_000_000)),
382            &th(),
383        );
384        assert_eq!(r.worst(), PressureLevel::Ok);
385    }
386
387    #[test]
388    fn missing_disk_metrics_are_unknown_not_ok() {
389        let r = assess_root(DiskRootKind::TargetRoot, None, &th());
390        assert_eq!(r.bytes, PressureLevel::Unknown);
391        assert_eq!(r.inodes, PressureLevel::Unknown);
392        assert!(r.available_bytes.is_none());
393        // A zero-total df line is equally unassessable.
394        let z = assess_root(
395            DiskRootKind::TargetRoot,
396            Some(&facts("/x/y", 0, 0, 0)),
397            &th(),
398        );
399        assert_eq!(z.bytes, PressureLevel::Unknown);
400    }
401
402    #[test]
403    fn target_root_inode_exhaustion_is_critical() {
404        // Plenty of bytes, but no inodes left.
405        let r = assess_root(
406            DiskRootKind::TargetRoot,
407            Some(&facts("/tmp/rch", 100_000, 90_000, 0)),
408            &th(),
409        );
410        assert_eq!(r.bytes, PressureLevel::Ok);
411        assert_eq!(r.inodes, PressureLevel::Critical);
412        assert_eq!(r.worst(), PressureLevel::Critical);
413    }
414
415    #[test]
416    fn log_pressure_drives_worker_worst() {
417        let report = WorkerDiskPressureReport {
418            worker_id: "css".to_string(),
419            telemetry: FreshnessVerdict::Fresh,
420            roots: vec![
421                assess_root(
422                    DiskRootKind::TargetRoot,
423                    Some(&facts("/tmp/rch", 100, 90, 5_000_000)),
424                    &th(),
425                ),
426                assess_root(
427                    DiskRootKind::Log,
428                    Some(&facts("/var/log/rch", 100, 3, 5_000_000)),
429                    &th(),
430                ),
431            ],
432        };
433        assert!(report.has_critical());
434        assert_eq!(report.worst(), PressureLevel::Critical);
435    }
436
437    // --- Telemetry gap ------------------------------------------------------
438
439    #[test]
440    fn telemetry_gap_caps_confidence_at_unknown() {
441        // All roots Ok, but the telemetry is stale: the report must not read Ok.
442        let report = WorkerDiskPressureReport {
443            worker_id: "bil".to_string(),
444            telemetry: FreshnessVerdict::Stale,
445            roots: vec![assess_root(
446                DiskRootKind::TargetRoot,
447                Some(&facts("/tmp/rch", 100, 90, 5_000_000)),
448                &th(),
449            )],
450        };
451        assert_eq!(report.worst(), PressureLevel::Unknown);
452        // A genuine critical still wins over the telemetry-gap floor.
453        let crit = WorkerDiskPressureReport {
454            worker_id: "bil".to_string(),
455            telemetry: FreshnessVerdict::Unknown,
456            roots: vec![assess_root(
457                DiskRootKind::TargetRoot,
458                Some(&facts("/tmp/rch", 100, 1, 5_000_000)),
459                &th(),
460            )],
461        };
462        assert_eq!(crit.worst(), PressureLevel::Critical);
463    }
464
465    // --- Worker filesystem error vs product compile failure -----------------
466
467    #[test]
468    fn enospc_without_compiler_diagnostics_is_worker_environment() {
469        let stderr = "error: failed to write to `/tmp/rch/target/x.o`: No space left on device";
470        assert_eq!(
471            classify_exec_failure(stderr),
472            ExecFailureClass::WorkerEnvironment,
473            "a disk-write failure is not the project failing to compile"
474        );
475    }
476
477    #[test]
478    fn enospc_with_real_compiler_diagnostics_is_product_compile() {
479        // Real rustc coded diagnostic proves the code genuinely failed to compile.
480        let stderr =
481            "error[E0308]: mismatched types\n  --> src/lib.rs:1:1\nNo space left on device";
482        assert_eq!(
483            classify_exec_failure(stderr),
484            ExecFailureClass::ProductCompile
485        );
486    }
487
488    #[test]
489    fn real_compile_error_alone_is_product_compile() {
490        let stderr = "error[E0425]: cannot find value `x`\nerror: aborting due to 1 previous error";
491        assert_eq!(
492            classify_exec_failure(stderr),
493            ExecFailureClass::ProductCompile
494        );
495        assert!(mentions_compiler_diagnostic(stderr));
496    }
497
498    #[test]
499    fn could_not_compile_tail_counts_as_diagnostic() {
500        assert!(mentions_compiler_diagnostic(
501            "error: could not compile `acme` due to 2 previous errors"
502        ));
503    }
504
505    #[test]
506    fn neither_signal_is_indeterminate() {
507        assert_eq!(
508            classify_exec_failure("connection reset by peer"),
509            ExecFailureClass::Indeterminate
510        );
511    }
512
513    #[test]
514    fn bare_error_line_is_not_a_compiler_diagnostic() {
515        // `error:` without a code/abort tail must not be mistaken for a compile
516        // diagnostic — otherwise ENOSPC's "error: failed to write" would be
517        // misclassified as a product compile failure.
518        assert!(!mentions_compiler_diagnostic(
519            "error: failed to write output: No space left on device"
520        ));
521    }
522
523    // --- Serde / contract ---------------------------------------------------
524
525    #[test]
526    fn report_serializes_with_stable_tokens() {
527        let report = WorkerDiskPressureReport {
528            worker_id: "css".to_string(),
529            telemetry: FreshnessVerdict::Fresh,
530            roots: vec![assess_root(
531                DiskRootKind::TargetRoot,
532                Some(&facts("/tmp/rch", 100, 1, 0)),
533                &th(),
534            )],
535        };
536        let value = serde_json::to_value(&report).unwrap();
537        assert_eq!(value["roots"][0]["kind"], "target_root");
538        assert_eq!(value["roots"][0]["bytes"], "critical");
539        assert_eq!(value["roots"][0]["inodes"], "critical");
540        let back: WorkerDiskPressureReport = serde_json::from_value(value).unwrap();
541        assert_eq!(back, report);
542    }
543
544    #[test]
545    fn pressure_level_worse_aggregation() {
546        assert_eq!(
547            PressureLevel::Ok.worse(PressureLevel::Warning),
548            PressureLevel::Warning
549        );
550        assert_eq!(
551            PressureLevel::Critical.worse(PressureLevel::Unknown),
552            PressureLevel::Critical
553        );
554        assert_eq!(
555            PressureLevel::Ok.worse(PressureLevel::Unknown),
556            PressureLevel::Unknown
557        );
558    }
559}