monitrs_core/diagnostics/rules/
storage.rs1use crate::model::{
4 MeasuredValue, Measurement, MetricState, PressureId, PressureState, Severity, SystemSnapshot,
5};
6use crate::units::{Ellipsis, format_age, truncate_tail};
7
8use super::super::{DiagnosticRule, Evidence, Finding, HistoryWindow, Thresholds};
9use super::{SUSTAINED_CONFIDENCE, as_percent};
10
11pub const DISK_SUSTAINED_BUSY: &str = "disk.sustained_busy";
13
14const DEVICE_NAME_WIDTH: usize = 24;
16
17const SUMMARY_DEVICES: usize = 3;
19
20#[derive(Clone, Copy, Debug)]
38pub struct DiskBusyRule {
39 thresholds: Thresholds,
40}
41
42impl DiskBusyRule {
43 #[must_use]
45 pub const fn new(thresholds: Thresholds) -> Self {
46 Self { thresholds }
47 }
48}
49
50impl DiagnosticRule for DiskBusyRule {
51 fn id(&self) -> &'static str {
52 DISK_SUSTAINED_BUSY
53 }
54
55 fn evaluate(&self, current: &SystemSnapshot, _history: &HistoryWindow<'_>) -> Option<Finding> {
56 let signal = current.pressure.signal(PressureId::Disk)?;
57 let state = *signal.state.fresh()?;
58 let severity = match state {
59 PressureState::Normal => return None,
60 PressureState::Watch => Severity::Watch,
61 PressureState::Critical => Severity::Critical,
62 };
63 let threshold = if severity == Severity::Critical {
64 self.thresholds.disk_busy_critical_percent
65 } else {
66 self.thresholds.disk_busy_watch_percent
67 };
68
69 let mut evidence = vec![Evidence::current(Measurement::new(
70 "threshold",
71 MeasuredValue::Percent(as_percent(threshold)),
72 ))];
73 if let Some(raw) = signal.raw {
74 evidence.push(Evidence::current(raw));
75 }
76 if let Some(held) = signal.held_for {
77 evidence.push(Evidence::current(Measurement::new(
78 "held for",
79 MeasuredValue::Duration(held),
80 )));
81 }
82
83 let mut devices: Vec<(&str, crate::units::Percent)> = current
86 .disks
87 .iter()
88 .filter_map(|disk| disk.busy.fresh().map(|busy| (&*disk.device, *busy)))
89 .collect();
90 devices.sort_by(|left, right| {
91 right
92 .1
93 .value()
94 .total_cmp(&left.1.value())
95 .then_with(|| left.0.cmp(right.0))
96 });
97 let named: Vec<String> = devices
98 .iter()
99 .take(SUMMARY_DEVICES)
100 .map(|(device, busy)| {
101 format!(
102 "{} {busy}",
103 truncate_tail(device, DEVICE_NAME_WIDTH, Ellipsis::Ascii)
104 )
105 })
106 .collect();
107 let held = signal
108 .held_for
109 .map_or_else(|| "the sustained window".to_owned(), format_age);
110 let observed = if named.is_empty() {
111 String::new()
112 } else {
113 format!(" Busiest observed devices: {}.", named.join(", "))
114 };
115 let summary = format!(
116 "A block device has been at or above {} busy for {held}. Device busy is the share of \
117 wall time with at least one request in flight; it is not filesystem capacity, and a \
118 device working hard is not a device in trouble.{observed}",
119 as_percent(threshold),
120 );
121
122 Some(
123 Finding::new(
124 DISK_SUSTAINED_BUSY,
125 severity,
126 "Disk device sustained busy",
127 summary,
128 SUSTAINED_CONFIDENCE,
129 )
130 .with_evidence(evidence),
131 )
132 }
133}
134
135#[must_use]
140pub fn disk_signal_ready(current: &SystemSnapshot) -> bool {
141 current
142 .pressure
143 .signal(PressureId::Disk)
144 .is_some_and(|signal| matches!(signal.state, MetricState::Available(_)))
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::diagnostics::PressureEngine;
151 use crate::diagnostics::fixtures::{Timeline, set_cpu, set_disk_busy, snapshot};
152 use core::time::Duration;
153
154 fn rule() -> DiskBusyRule {
155 DiskBusyRule::new(Thresholds::default().sanitized())
156 }
157
158 fn derived(busy: f32, count: usize) -> (Timeline, SystemSnapshot) {
162 let mut engine = PressureEngine::default();
163 let mut timeline = Timeline::new(Duration::from_secs(1));
164 let mut current = timeline.push(|snapshot| {
165 set_cpu(snapshot, 5.0);
166 set_disk_busy(snapshot, "nvme0n1", busy);
167 });
168 current.pressure = engine.observe(¤t);
169 for _ in 1..count {
170 let mut next = timeline.push(|snapshot| {
171 set_cpu(snapshot, 5.0);
172 set_disk_busy(snapshot, "nvme0n1", busy);
173 });
174 next.pressure = engine.observe(&next);
175 current = next;
176 }
177 (timeline, current)
178 }
179
180 #[test]
181 fn a_quiet_device_produces_no_finding() {
182 let (timeline, current) = derived(4.0, 20);
183 assert!(rule().evaluate(¤t, &timeline.window()).is_none());
184 }
185
186 #[test]
187 fn a_device_busy_for_the_sustained_window_is_a_finding() {
188 let (timeline, current) = derived(85.0, 20);
189 let finding = rule()
190 .evaluate(¤t, &timeline.window())
191 .expect("the disk signal has escalated");
192
193 assert_eq!(finding.severity, Severity::Watch);
194 assert_eq!(finding.rule_id, DISK_SUSTAINED_BUSY);
195 assert!(
196 finding.summary.contains("nvme0n1 85%"),
197 "the finding must name the device: {}",
198 finding.summary
199 );
200 assert!(
201 finding.summary.contains("not filesystem capacity"),
202 "§7.3 keeps the two metrics apart: {}",
203 finding.summary
204 );
205 }
206
207 #[test]
208 fn a_saturated_device_escalates_to_critical() {
209 let (timeline, current) = derived(99.0, 20);
210 let finding = rule()
211 .evaluate(¤t, &timeline.window())
212 .expect("the disk signal has escalated");
213 assert_eq!(finding.severity, Severity::Critical);
214 let labels: Vec<&str> = finding
215 .evidence
216 .iter()
217 .map(|item| item.measurement.label)
218 .collect();
219 assert!(labels.contains(&"device busy"), "{labels:?}");
220 assert!(labels.contains(&"held for"), "{labels:?}");
221 }
222
223 #[test]
224 fn a_brief_burst_does_not_fire_because_the_signal_has_not_escalated() {
225 let (timeline, current) = derived(99.0, 5);
226 assert!(
227 rule().evaluate(¤t, &timeline.window()).is_none(),
228 "five samples cannot sustain a ten-sample condition"
229 );
230 }
231
232 #[test]
233 fn nothing_fires_before_pressure_has_been_derived() {
234 let mut timeline = Timeline::new(Duration::from_secs(1));
235 let current = timeline.push_many(20, |snapshot| set_disk_busy(snapshot, "nvme0n1", 99.0));
236 assert!(!disk_signal_ready(¤t));
237 assert!(
238 rule().evaluate(¤t, &timeline.window()).is_none(),
239 "the collector's warming-up radar carries no state to report"
240 );
241 }
242
243 #[test]
244 fn a_platform_without_a_busy_figure_never_fires() {
245 let mut engine = PressureEngine::default();
246 let mut timeline = Timeline::new(Duration::from_secs(1));
247 let mut current = snapshot();
248 for _ in 0..20 {
249 let mut next = timeline.push(|snapshot| {
250 set_disk_busy(snapshot, "disk0", 99.0);
251 if let Some(device) = snapshot.disks.first_mut() {
252 device.busy = MetricState::Unsupported;
253 }
254 });
255 next.pressure = engine.observe(&next);
256 current = next;
257 }
258 assert!(!disk_signal_ready(¤t));
259 assert!(rule().evaluate(¤t, &timeline.window()).is_none());
260 }
261
262 #[test]
263 fn the_busiest_device_is_named_first() {
264 let mut engine = PressureEngine::default();
265 let mut timeline = Timeline::new(Duration::from_secs(1));
266 let mut current = snapshot();
267 for _ in 0..20 {
268 let mut next = timeline.push(|snapshot| {
269 set_disk_busy(snapshot, "nvme0n1", 30.0);
270 set_disk_busy(snapshot, "nvme1n1", 96.0);
271 });
272 next.pressure = engine.observe(&next);
273 current = next;
274 }
275 let finding = rule()
276 .evaluate(¤t, &timeline.window())
277 .expect("the busiest device escalated the signal");
278 assert!(
279 finding
280 .summary
281 .contains("Busiest observed devices: nvme1n1 96%, nvme0n1 30%"),
282 "{}",
283 finding.summary
284 );
285 }
286}