1use crate::history::{ContributorMetric, HistoryMetric};
4use crate::model::{Confidence, MeasuredValue, Measurement, Severity, SystemSnapshot};
5use crate::units::{Percent, format_duration};
6
7use super::super::{DiagnosticRule, Evidence, Finding, HistoryWindow, Thresholds};
8use super::{SUSTAINED_CONFIDENCE, as_count, as_percent, escalate, ratio, share_contributors};
9
10pub const MEMORY_AVAILABILITY_LOW: &str = "memory.availability_low";
12pub const SWAP_ACTIVITY: &str = "memory.swap_activity";
14
15#[derive(Clone, Copy, Debug)]
26pub struct MemoryAvailabilityLowRule {
27 thresholds: Thresholds,
28}
29
30impl MemoryAvailabilityLowRule {
31 #[must_use]
33 pub const fn new(thresholds: Thresholds) -> Self {
34 Self { thresholds }
35 }
36}
37
38impl DiagnosticRule for MemoryAvailabilityLowRule {
39 fn id(&self) -> &'static str {
40 MEMORY_AVAILABILITY_LOW
41 }
42
43 fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
44 let thresholds = &self.thresholds;
45 let span = thresholds.sustained_window;
46 let required = thresholds.sustained_samples;
47 let minimum = thresholds.minimum_samples();
48
49 let watch = history.count_at_least(
52 HistoryMetric::MemoryUsedShare,
53 span,
54 f64::from(thresholds.memory_watch_used_percent()),
55 );
56 let critical = history.count_at_least(
57 HistoryMetric::MemoryUsedShare,
58 span,
59 f64::from(thresholds.memory_critical_used_percent()),
60 );
61 let severity = escalate(
62 watch.sustained(required, minimum),
63 critical.sustained(required, minimum),
64 )?;
65 let (counted, available_threshold) = if severity == Severity::Critical {
66 (critical, thresholds.memory_critical_available_percent)
67 } else {
68 (watch, thresholds.memory_watch_available_percent)
69 };
70
71 let limit = current.memory.effective_limit_bytes();
72 let mut evidence = vec![
73 Evidence::new(
74 Measurement::new(
75 "samples at or below the available threshold",
76 MeasuredValue::Count(as_count(counted.matched)),
77 ),
78 counted.window(),
79 ),
80 Evidence::current(Measurement::new(
81 "available threshold",
82 MeasuredValue::Percent(as_percent(available_threshold)),
83 )),
84 Evidence::current(Measurement::new(
85 "memory limit",
86 MeasuredValue::Bytes(limit),
87 )),
88 ];
89
90 let mut available_share = None;
91 if let Some(&available) = current.memory.available.fresh() {
92 evidence.push(Evidence::current(Measurement::new(
93 "available",
94 MeasuredValue::Bytes(available),
95 )));
96 if let Some(share) = Percent::ratio(available, limit) {
97 available_share = Some(share);
98 evidence.push(Evidence::current(Measurement::new(
99 "available share",
100 MeasuredValue::Percent(share),
101 )));
102 }
103 }
104 if let Some(&swap_used) = current.memory.swap.used.fresh() {
105 evidence.push(Evidence::current(Measurement::new(
106 "swap used",
107 MeasuredValue::Bytes(swap_used),
108 )));
109 }
110
111 let observed = available_share.map_or_else(
112 || "Available memory".to_owned(),
113 |share| format!("Available memory is {share} of the effective limit and"),
114 );
115 let mut summary = format!(
116 "{observed} was at or below {} in {} of the last {} samples ({}). Memory accounting: {}.",
117 as_percent(available_threshold),
118 counted.matched,
119 counted.considered,
120 format_duration(counted.span),
121 current.memory.semantics.description(),
122 );
123 if let Some(sample) = history.selected()
124 && let Some(contributors) = share_contributors(
125 &sample.contributors,
126 ContributorMetric::ResidentMemory,
127 current.memory.total_bytes,
128 )
129 {
130 summary.push_str(&format!(
131 " Largest observed resident sets: {contributors} of total memory."
132 ));
133 }
134
135 Some(
136 Finding::new(
137 MEMORY_AVAILABILITY_LOW,
138 severity,
139 "Available memory low",
140 summary,
141 SUSTAINED_CONFIDENCE,
142 )
143 .with_evidence(evidence),
144 )
145 }
146}
147
148#[derive(Clone, Copy, Debug)]
158pub struct SwapActivityRule {
159 thresholds: Thresholds,
160}
161
162impl SwapActivityRule {
163 #[must_use]
165 pub const fn new(thresholds: Thresholds) -> Self {
166 Self { thresholds }
167 }
168}
169
170impl DiagnosticRule for SwapActivityRule {
171 fn id(&self) -> &'static str {
172 SWAP_ACTIVITY
173 }
174
175 fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
176 let thresholds = &self.thresholds;
177 let swap = ¤t.memory.swap;
178 if !swap.is_enabled() {
179 return None;
180 }
181 let (Some(in_rate), Some(out_rate)) = (swap.in_rate.fresh(), swap.out_rate.fresh()) else {
182 return None;
185 };
186 let total = in_rate.per_second() + out_rate.per_second();
187 let severity = escalate(
188 total >= thresholds.swap_watch_bytes_per_second,
189 total >= thresholds.swap_critical_bytes_per_second,
190 )?;
191 let threshold = if severity == Severity::Critical {
192 thresholds.swap_critical_bytes_per_second
193 } else {
194 thresholds.swap_watch_bytes_per_second
195 };
196
197 let mut evidence = vec![
198 Evidence::current(Measurement::new(
199 "swap in",
200 MeasuredValue::ByteRate(*in_rate),
201 )),
202 Evidence::current(Measurement::new(
203 "swap out",
204 MeasuredValue::ByteRate(*out_rate),
205 )),
206 ];
207 if let Some(&used) = swap.used.fresh() {
208 evidence.push(Evidence::current(Measurement::new(
209 "swap used",
210 MeasuredValue::Bytes(used),
211 )));
212 }
213 if let Some(&available) = current.memory.available.fresh() {
214 evidence.push(Evidence::current(Measurement::new(
215 "available",
216 MeasuredValue::Bytes(available),
217 )));
218 }
219
220 let growth = history
223 .trend(HistoryMetric::SwapUsed, thresholds.sustained_window)
224 .filter(|(_, _, span)| !span.is_zero());
225 let mut confidence = Confidence::Low;
226 let mut corroboration = String::new();
227 if let Some((first, last, span)) = growth
228 && last > first
229 {
230 confidence = SUSTAINED_CONFIDENCE;
231 corroboration = format!(
232 " Swap in use also grew over the last {}.",
233 format_duration(span)
234 );
235 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
239 let grown = (last - first).max(0.0) as u64;
240 evidence.push(Evidence::new(
241 Measurement::new("swap used growth", MeasuredValue::Bytes(grown)),
242 crate::diagnostics::TimeWindow::new(span, 2),
243 ));
244 }
245
246 let multiple = ratio(total, threshold)
247 .map_or_else(String::new, |value| format!(" ({value:.1}x the threshold)"));
248 let summary = format!(
249 "Pages are being moved between memory and swap{multiple}. Swap activity is the metric \
250 that indicates memory distress; a large but idle swap area is not.{corroboration}"
251 );
252
253 Some(
254 Finding::new(
255 SWAP_ACTIVITY,
256 severity,
257 "Swap in/out activity",
258 summary,
259 confidence,
260 )
261 .with_evidence(evidence),
262 )
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::diagnostics::fixtures::{Timeline, add_process, set_memory, set_swap, snapshot};
270 use crate::model::{MetricState, ProcessState};
271 use core::time::Duration;
272
273 const TOTAL: u64 = 32 * 1024 * 1024 * 1024;
274 const SWAP_TOTAL: u64 = 8 * 1024 * 1024 * 1024;
275
276 fn memory_rule() -> MemoryAvailabilityLowRule {
277 MemoryAvailabilityLowRule::new(Thresholds::default().sanitized())
278 }
279
280 fn swap_rule() -> SwapActivityRule {
281 SwapActivityRule::new(Thresholds::default().sanitized())
282 }
283
284 fn timeline() -> Timeline {
285 Timeline::new(Duration::from_secs(1))
286 }
287
288 fn available(percent: u64) -> u64 {
290 TOTAL / 100 * percent
291 }
292
293 #[test]
294 fn plenty_of_available_memory_produces_no_finding() {
295 let mut timeline = timeline();
296 let current = timeline.push_many(20, |snapshot| set_memory(snapshot, TOTAL, available(60)));
297 assert!(
298 memory_rule()
299 .evaluate(¤t, &timeline.window())
300 .is_none()
301 );
302 }
303
304 #[test]
305 fn sustained_low_availability_is_a_watch() {
306 let mut timeline = timeline();
307 let current = timeline.push_many(20, |snapshot| set_memory(snapshot, TOTAL, available(12)));
308 let finding = memory_rule()
309 .evaluate(¤t, &timeline.window())
310 .expect("12% available is below the 15% watch threshold");
311
312 assert_eq!(finding.severity, Severity::Watch);
313 assert_eq!(finding.title, "Available memory low");
314 assert!(
315 finding.summary.contains("Memory accounting:"),
316 "{}",
317 finding.summary
318 );
319 }
320
321 #[test]
322 fn sustained_very_low_availability_is_critical() {
323 let mut timeline = timeline();
324 let current = timeline.push_many(20, |snapshot| set_memory(snapshot, TOTAL, available(3)));
325 let finding = memory_rule()
326 .evaluate(¤t, &timeline.window())
327 .expect("3% available is below the 5% critical threshold");
328 assert_eq!(finding.severity, Severity::Critical);
329 }
330
331 #[test]
332 fn a_brief_dip_in_availability_is_not_a_finding() {
333 let mut timeline = timeline();
334 timeline.push_many(19, |snapshot| set_memory(snapshot, TOTAL, available(50)));
335 let current = timeline.push(|snapshot| set_memory(snapshot, TOTAL, available(1)));
336 assert!(
337 memory_rule()
338 .evaluate(¤t, &timeline.window())
339 .is_none()
340 );
341 }
342
343 #[test]
344 fn the_finding_reports_bytes_as_evidence_and_shares_in_the_summary() {
345 let mut timeline = timeline();
346 let current = timeline.push_many(20, |snapshot| set_memory(snapshot, TOTAL, available(4)));
347 let finding = memory_rule()
348 .evaluate(¤t, &timeline.window())
349 .expect("sustained low availability");
350
351 let labels: Vec<&str> = finding
352 .evidence
353 .iter()
354 .map(|item| item.measurement.label)
355 .collect();
356 assert!(labels.contains(&"available"), "{labels:?}");
357 assert!(labels.contains(&"memory limit"), "{labels:?}");
358 assert!(labels.contains(&"available share"), "{labels:?}");
359
360 assert!(!finding.summary.contains("GiB"), "{}", finding.summary);
362 assert!(!finding.summary.contains(" GB"), "{}", finding.summary);
363 }
364
365 #[test]
366 fn a_cgroup_limit_is_the_ceiling_the_finding_is_measured_against() {
367 let limit = 2 * 1024 * 1024 * 1024;
368 let mut timeline = timeline();
369 let current = timeline.push_many(20, |snapshot| {
370 set_memory(snapshot, TOTAL, 100 * 1024 * 1024);
372 snapshot.memory.cgroup_limit_bytes = MetricState::Available(limit);
373 snapshot.memory.usage = Percent::ratio(limit - 100 * 1024 * 1024, limit)
374 .map_or(MetricState::Unsupported, MetricState::Available);
375 });
376 let finding = memory_rule()
377 .evaluate(¤t, &timeline.window())
378 .expect("100 MiB of a 2 GiB limit is critical");
379 assert_eq!(finding.severity, Severity::Critical);
380 let limit_evidence = finding
381 .evidence
382 .iter()
383 .find(|item| item.measurement.label == "memory limit")
384 .expect("the ceiling is evidence");
385 assert_eq!(
386 limit_evidence.measurement.value,
387 MeasuredValue::Bytes(limit)
388 );
389 }
390
391 #[test]
392 fn unavailable_memory_samples_do_not_count_as_low_availability() {
393 let mut timeline = timeline();
394 let current = timeline.push_many(20, |snapshot| {
395 snapshot.memory.total_bytes = TOTAL;
396 snapshot.memory.usage = MetricState::PermissionDenied;
397 snapshot.memory.available = MetricState::PermissionDenied;
398 });
399 assert!(
400 memory_rule()
401 .evaluate(¤t, &timeline.window())
402 .is_none()
403 );
404 }
405
406 #[test]
407 fn the_summary_names_the_largest_resident_sets_as_shares() {
408 let mut timeline = timeline();
409 let current = timeline.push_many(20, |snapshot| {
410 set_memory(snapshot, TOTAL, available(4));
411 add_process(
412 snapshot,
413 31_842,
414 "rustc",
415 Some(287.0),
416 Some(2_600_000_000),
417 ProcessState::Running,
418 );
419 });
420 let finding = memory_rule()
421 .evaluate(¤t, &timeline.window())
422 .expect("sustained low availability");
423 assert!(
424 finding
425 .summary
426 .contains("Largest observed resident sets: rustc"),
427 "{}",
428 finding.summary
429 );
430 }
431
432 #[test]
433 fn no_swap_configured_produces_no_swap_finding() {
434 let timeline = timeline();
435 assert!(
436 swap_rule()
437 .evaluate(&snapshot(), &timeline.window())
438 .is_none()
439 );
440 }
441
442 #[test]
443 fn an_idle_swap_area_produces_no_finding_however_full_it_is() {
444 let mut timeline = timeline();
445 let current = timeline.push_many(20, |snapshot| {
446 set_swap(snapshot, SWAP_TOTAL, SWAP_TOTAL - 1024, 0.0, 0.0);
447 });
448 assert!(
449 swap_rule().evaluate(¤t, &timeline.window()).is_none(),
450 "capacity in use is not activity"
451 );
452 }
453
454 #[test]
455 fn swap_activity_from_one_sample_is_marked_low_confidence() {
456 let mut timeline = timeline();
457 let current = timeline.push_many(20, |snapshot| {
458 set_swap(snapshot, SWAP_TOTAL, 1024, 2_000_000.0, 0.0);
459 });
460 let finding = swap_rule()
461 .evaluate(¤t, &timeline.window())
462 .expect("2 MiB/s is above the watch threshold");
463
464 assert_eq!(finding.severity, Severity::Watch);
465 assert_eq!(
466 finding.confidence,
467 Confidence::Low,
468 "§11.3: an inference from one sample is low confidence"
469 );
470 assert!(
471 finding.summary.contains("1.9x the threshold"),
472 "2 MB/s against a 1 MiB/s threshold: {}",
473 finding.summary
474 );
475 }
476
477 #[test]
478 fn growing_swap_usage_raises_the_confidence() {
479 let mut timeline = timeline();
480 let mut used = 1024u64;
481 let current = timeline.push_many(20, move |snapshot| {
482 used = used.saturating_add(16 * 1024 * 1024);
483 set_swap(snapshot, SWAP_TOTAL, used, 2_000_000.0, 0.0);
484 });
485 let finding = swap_rule()
486 .evaluate(¤t, &timeline.window())
487 .expect("swap is active");
488 assert_eq!(finding.confidence, Confidence::Medium);
489 assert!(finding.summary.contains("also grew"), "{}", finding.summary);
490 let labels: Vec<&str> = finding
491 .evidence
492 .iter()
493 .map(|item| item.measurement.label)
494 .collect();
495 assert!(labels.contains(&"swap used growth"), "{labels:?}");
496 }
497
498 #[test]
499 fn heavy_paging_escalates_to_critical() {
500 let mut timeline = timeline();
501 let current = timeline.push_many(20, |snapshot| {
502 set_swap(snapshot, SWAP_TOTAL, 1024, 12_000_000.0, 12_000_000.0);
503 });
504 let finding = swap_rule()
505 .evaluate(¤t, &timeline.window())
506 .expect("24 MiB/s combined is critical");
507 assert_eq!(finding.severity, Severity::Critical);
508 }
509
510 #[test]
511 fn a_platform_without_swap_rates_produces_no_finding() {
512 let mut timeline = timeline();
513 let current = timeline.push_many(20, |snapshot| {
514 set_swap(snapshot, SWAP_TOTAL, 1024, 0.0, 0.0);
515 snapshot.memory.swap.in_rate = MetricState::Unsupported;
516 snapshot.memory.swap.out_rate = MetricState::Unsupported;
517 });
518 assert!(swap_rule().evaluate(¤t, &timeline.window()).is_none());
519 }
520}