1use serde::{Deserialize, Serialize};
27
28use crate::telemetry_freshness::FreshnessVerdict;
29use crate::worker_facts::DiskRootFacts;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum PressureLevel {
35 Ok,
37 Warning,
39 Critical,
41 Unknown,
43}
44
45impl PressureLevel {
46 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum DiskRootKind {
84 TargetRoot,
86 CargoHome,
88 SyncRoot,
90 Log,
92 TempRoot,
94 Other,
96}
97
98impl DiskRootKind {
99 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct RootPressure {
137 pub kind: DiskRootKind,
138 pub path: String,
139 pub bytes: PressureLevel,
141 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 #[must_use]
154 pub fn worst(&self) -> PressureLevel {
155 self.bytes.worse(self.inodes)
156 }
157}
158
159#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct WorkerDiskPressureReport {
217 pub worker_id: String,
218 pub telemetry: FreshnessVerdict,
221 pub roots: Vec<RootPressure>,
222}
223
224impl WorkerDiskPressureReport {
225 #[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 #[must_use]
242 pub fn has_critical(&self) -> bool {
243 self.roots
244 .iter()
245 .any(|r| r.worst() == PressureLevel::Critical)
246 }
247
248 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum ExecFailureClass {
269 WorkerEnvironment,
271 ProductCompile,
273 Indeterminate,
275}
276
277impl ExecFailureClass {
278 #[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#[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#[must_use]
304pub fn mentions_compiler_diagnostic(stderr: &str) -> bool {
305 let s = stderr.to_ascii_lowercase();
306 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#[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 (true, true) => ExecFailureClass::ProductCompile,
326 (true, false) => ExecFailureClass::WorkerEnvironment,
328 (false, true) => ExecFailureClass::ProductCompile,
330 (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 #[test]
355 fn critical_pressure_when_bytes_nearly_full() {
356 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 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 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 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 #[test]
440 fn telemetry_gap_caps_confidence_at_unknown() {
441 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 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 #[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 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 assert!(!mentions_compiler_diagnostic(
519 "error: failed to write output: No space left on device"
520 ));
521 }
522
523 #[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}