Skip to main content

monitrs_core/diagnostics/rules/
memory.rs

1//! Memory availability and swap activity (§11.2).
2
3use 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
10/// Rule id for low available memory.
11pub const MEMORY_AVAILABILITY_LOW: &str = "memory.availability_low";
12/// Rule id for swap in/out activity.
13pub const SWAP_ACTIVITY: &str = "memory.swap_activity";
14
15/// Available memory sustained below its threshold (§11.2).
16///
17/// Judged against the ceiling that actually applies to this process tree — the
18/// cgroup limit where there is one, the host total otherwise (§9.2) — and reported
19/// alongside the platform's memory semantics, because §8.4 forbids treating the two
20/// definitions as interchangeable.
21///
22/// The finding says *available memory is low*. It draws no conclusion about what
23/// will happen next: §11.3 forbids diagnosing a kill or an allocation failure from
24/// an availability figure.
25#[derive(Clone, Copy, Debug)]
26pub struct MemoryAvailabilityLowRule {
27    thresholds: Thresholds,
28}
29
30impl MemoryAvailabilityLowRule {
31    /// Builds the rule from sanitized thresholds.
32    #[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        // History retains the *used* share, so the available-share thresholds are
50        // counted in used terms (§8.5).
51        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/// Swap being read back or written out (§11.2).
149///
150/// A large but idle swap file is unremarkable; the metric that matters is the
151/// *rate*, which is why this rule ignores swap usage as a trigger and reports it
152/// only as supporting evidence.
153///
154/// Confidence is [`Confidence::Low`] when only the instantaneous rate supports the
155/// finding, and rises to medium when swap in use also grew across the window —
156/// §11.3 requires a one-sample inference to be marked as one.
157#[derive(Clone, Copy, Debug)]
158pub struct SwapActivityRule {
159    thresholds: Thresholds,
160}
161
162impl SwapActivityRule {
163    /// Builds the rule from sanitized thresholds.
164    #[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 = &current.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            // §26: a platform that does not report swap rates has not reported
183            // zero swap activity.
184            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        // A rising amount of swap in use across the window is independent
221        // corroboration; without it this is one sample of evidence.
222        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            // The difference of two byte counts is a byte count; it is only
236            // floating point because history stores comparable scalars, and the
237            // guard above plus `max(0.0)` make the narrowing lossless in practice.
238            #[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    /// `percent` of total memory available.
289    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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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        // Byte counts are a display decision, so they must not be baked into text.
361        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            // Comfortable against the host total, critical against the container.
371            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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &timeline.window()).is_none());
519    }
520}