monitrs_core/diagnostics/rules/
psi.rs1use core::time::Duration;
14
15use crate::model::{MeasuredValue, Measurement, PressureId, PsiResource, Severity, SystemSnapshot};
16
17use super::super::{
18 DiagnosticRule, Evidence, Finding, HistoryWindow, Thresholds, TimeWindow, signals,
19};
20use super::{SUSTAINED_CONFIDENCE, as_percent, escalate, ratio};
21
22pub const PSI_MEMORY_ELEVATED: &str = "psi.memory_elevated";
24pub const PSI_IO_ELEVATED: &str = "psi.io_elevated";
26
27const AVG10: Duration = Duration::from_secs(10);
29const AVG60: Duration = Duration::from_secs(60);
30const AVG300: Duration = Duration::from_secs(300);
31
32fn evaluate_psi(
38 rule_id: &'static str,
39 id: PressureId,
40 title: &'static str,
41 waiting_for: &'static str,
42 thresholds: &Thresholds,
43 current: &SystemSnapshot,
44) -> Option<Finding> {
45 let psi = current.pressure.psi.fresh()?;
46 let resource: &PsiResource = signals::psi_resource(psi, id);
47 let some10 = f64::from(resource.some_avg10.value());
48
49 let severity = escalate(
50 some10 >= f64::from(thresholds.psi_watch_percent),
51 some10 >= f64::from(thresholds.psi_critical_percent),
52 )?;
53 let threshold = if severity == Severity::Critical {
54 thresholds.psi_critical_percent
55 } else {
56 thresholds.psi_watch_percent
57 };
58
59 let mut evidence = vec![
60 Evidence::new(
61 Measurement::new("some avg10", MeasuredValue::Percent(resource.some_avg10)),
62 TimeWindow::moving_average(AVG10),
63 ),
64 Evidence::new(
65 Measurement::new("some avg60", MeasuredValue::Percent(resource.some_avg60)),
66 TimeWindow::moving_average(AVG60),
67 ),
68 Evidence::new(
69 Measurement::new("some avg300", MeasuredValue::Percent(resource.some_avg300)),
70 TimeWindow::moving_average(AVG300),
71 ),
72 Evidence::current(Measurement::new(
73 "threshold",
74 MeasuredValue::Percent(as_percent(threshold)),
75 )),
76 Evidence::current(Measurement::new(
77 "total stalled",
78 MeasuredValue::Duration(resource.total_stalled),
79 )),
80 ];
81 if let Some(full) = resource.full_avg10.fresh() {
84 evidence.push(Evidence::new(
85 Measurement::new("full avg10", MeasuredValue::Percent(*full)),
86 TimeWindow::moving_average(AVG10),
87 ));
88 }
89
90 let multiple = ratio(some10, f64::from(threshold))
91 .map_or_else(String::new, |value| format!(" ({value:.1}x the threshold)"));
92 let summary = format!(
93 "The kernel reports at least one task stalled {waiting_for} for {} of the last 10 seconds{multiple}, \
94 and {} of the last 60. A pressure share is stalled time, not utilization.",
95 resource.some_avg10, resource.some_avg60,
96 );
97
98 Some(
99 Finding::new(rule_id, severity, title, summary, SUSTAINED_CONFIDENCE)
100 .with_evidence(evidence),
101 )
102}
103
104#[derive(Clone, Copy, Debug)]
110pub struct MemoryPsiElevatedRule {
111 thresholds: Thresholds,
112}
113
114impl MemoryPsiElevatedRule {
115 #[must_use]
117 pub const fn new(thresholds: Thresholds) -> Self {
118 Self { thresholds }
119 }
120}
121
122impl DiagnosticRule for MemoryPsiElevatedRule {
123 fn id(&self) -> &'static str {
124 PSI_MEMORY_ELEVATED
125 }
126
127 fn evaluate(&self, current: &SystemSnapshot, _history: &HistoryWindow<'_>) -> Option<Finding> {
128 evaluate_psi(
129 PSI_MEMORY_ELEVATED,
130 PressureId::PsiMemory,
131 "Linux memory pressure stalls elevated",
132 "on memory reclaim",
133 &self.thresholds,
134 current,
135 )
136 }
137}
138
139#[derive(Clone, Copy, Debug)]
141pub struct IoPsiElevatedRule {
142 thresholds: Thresholds,
143}
144
145impl IoPsiElevatedRule {
146 #[must_use]
148 pub const fn new(thresholds: Thresholds) -> Self {
149 Self { thresholds }
150 }
151}
152
153impl DiagnosticRule for IoPsiElevatedRule {
154 fn id(&self) -> &'static str {
155 PSI_IO_ELEVATED
156 }
157
158 fn evaluate(&self, current: &SystemSnapshot, _history: &HistoryWindow<'_>) -> Option<Finding> {
159 evaluate_psi(
160 PSI_IO_ELEVATED,
161 PressureId::PsiIo,
162 "Linux I/O pressure stalls elevated",
163 "on block i/o",
164 &self.thresholds,
165 current,
166 )
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::diagnostics::fixtures::{Timeline, set_psi, snapshot};
174 use crate::model::{Confidence, MetricState};
175
176 fn memory_rule() -> MemoryPsiElevatedRule {
177 MemoryPsiElevatedRule::new(Thresholds::default().sanitized())
178 }
179
180 fn io_rule() -> IoPsiElevatedRule {
181 IoPsiElevatedRule::new(Thresholds::default().sanitized())
182 }
183
184 #[test]
185 fn no_psi_data_produces_no_finding_on_either_rule() {
186 let timeline = Timeline::new(Duration::from_secs(1));
187 let window = timeline.window();
188 let snapshot = snapshot();
189 assert!(snapshot.pressure.psi.fresh().is_none());
190 assert!(memory_rule().evaluate(&snapshot, &window).is_none());
191 assert!(io_rule().evaluate(&snapshot, &window).is_none());
192 }
193
194 #[test]
195 fn quiet_psi_produces_no_finding() {
196 let timeline = Timeline::new(Duration::from_secs(1));
197 let mut snapshot = snapshot();
198 set_psi(&mut snapshot, 30.0, 0.4, 0.9);
199 assert!(
200 memory_rule()
201 .evaluate(&snapshot, &timeline.window())
202 .is_none()
203 );
204 assert!(io_rule().evaluate(&snapshot, &timeline.window()).is_none());
205 }
206
207 #[test]
208 fn each_rule_reads_only_its_own_resource() {
209 let timeline = Timeline::new(Duration::from_secs(1));
210 let mut snapshot = snapshot();
211 set_psi(&mut snapshot, 90.0, 12.0, 0.0);
212
213 let memory = memory_rule()
214 .evaluate(&snapshot, &timeline.window())
215 .expect("memory psi is elevated");
216 assert_eq!(memory.rule_id, PSI_MEMORY_ELEVATED);
217 assert_eq!(memory.severity, Severity::Watch);
218 assert!(
219 io_rule().evaluate(&snapshot, &timeline.window()).is_none(),
220 "an idle i/o resource must not inherit the memory reading"
221 );
222 }
223
224 #[test]
225 fn elevated_io_psi_escalates_to_critical() {
226 let timeline = Timeline::new(Duration::from_secs(1));
227 let mut snapshot = snapshot();
228 set_psi(&mut snapshot, 0.0, 0.0, 62.0);
229 let finding = io_rule()
230 .evaluate(&snapshot, &timeline.window())
231 .expect("62% stalled is critical");
232 assert_eq!(finding.severity, Severity::Critical);
233 assert_eq!(finding.title, "Linux I/O pressure stalls elevated");
234 }
235
236 #[test]
237 fn the_evidence_window_is_the_span_the_average_covers() {
238 let timeline = Timeline::new(Duration::from_secs(1));
239 let mut snapshot = snapshot();
240 set_psi(&mut snapshot, 0.0, 45.0, 0.0);
241 let finding = memory_rule()
242 .evaluate(&snapshot, &timeline.window())
243 .expect("elevated memory psi");
244
245 let avg10 = finding
246 .evidence
247 .iter()
248 .find(|item| item.measurement.label == "some avg10")
249 .expect("avg10 is evidence");
250 assert_eq!(avg10.window.span, AVG10);
251 assert!(
252 !avg10.window.is_current_sample(),
253 "one read of avg10 still covers ten seconds"
254 );
255
256 let labels: Vec<&str> = finding
257 .evidence
258 .iter()
259 .map(|item| item.measurement.label)
260 .collect();
261 assert!(labels.contains(&"some avg60"), "{labels:?}");
262 assert!(labels.contains(&"full avg10"), "{labels:?}");
263 assert!(labels.contains(&"total stalled"), "{labels:?}");
264 }
265
266 #[test]
267 fn a_kernel_without_the_full_figure_still_produces_a_finding() {
268 let timeline = Timeline::new(Duration::from_secs(1));
269 let mut snapshot = snapshot();
270 set_psi(&mut snapshot, 0.0, 45.0, 0.0);
271 if let MetricState::Available(psi) = &mut snapshot.pressure.psi {
272 psi.memory.full_avg10 = MetricState::Unsupported;
273 }
274 let finding = memory_rule()
275 .evaluate(&snapshot, &timeline.window())
276 .expect("some avg10 is enough to fire");
277 assert!(
278 !finding
279 .evidence
280 .iter()
281 .any(|item| item.measurement.label == "full avg10")
282 );
283 }
284
285 #[test]
286 fn the_summary_says_a_pressure_share_is_not_a_utilization() {
287 let timeline = Timeline::new(Duration::from_secs(1));
288 let mut snapshot = snapshot();
289 set_psi(&mut snapshot, 0.0, 45.0, 0.0);
290 let finding = memory_rule()
291 .evaluate(&snapshot, &timeline.window())
292 .expect("elevated memory psi");
293 assert!(
294 finding.summary.contains("not utilization"),
295 "{}",
296 finding.summary
297 );
298 assert_eq!(finding.confidence, Confidence::Medium);
299 }
300
301 #[test]
302 fn stale_psi_does_not_produce_a_finding() {
303 let timeline = Timeline::new(Duration::from_secs(1));
304 let mut snapshot = snapshot();
305 set_psi(&mut snapshot, 0.0, 99.0, 0.0);
306 snapshot.pressure.psi = snapshot.pressure.psi.into_stale(Duration::from_secs(5));
307 assert!(
308 memory_rule()
309 .evaluate(&snapshot, &timeline.window())
310 .is_none(),
311 "a retained value is not a current measurement (§4)"
312 );
313 }
314}