Skip to main content

mz_metrics/
usage.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Resource usage observations for the current process.
11//!
12//! This module is a mechanism, not a policy. It reports what each source says, under that
13//! source's own name, and never combines two sources into a third figure. The discrepancies
14//! between sources carry information: cgroup memory far above `VmRSS` means page cache or kernel
15//! memory is charged to the replica, which is exactly the case a single fused "memory" number
16//! hides. Deciding which source answers "how much memory is this replica using" belongs to the
17//! SQL views built on top.
18//!
19//! Peaks are observations too. `cgroup memory.peak` and `getrusage`'s `ru_maxrss` are high-water
20//! marks the kernel maintains itself, so they carry no sampling error and outlive anything short
21//! of the process exiting. Only a source with no kernel-side peak gets one folded here, reported
22//! under a distinct metric name so a caller can tell an exact peak from a sampled one.
23
24use std::collections::BTreeMap;
25use std::convert::Infallible;
26use std::path::{Path, PathBuf};
27use std::sync::Mutex;
28
29use anyhow::Context;
30use mz_ore::cast::CastFrom;
31use mz_ore::cgroup::CgroupV2;
32use mz_ore::metric;
33use mz_ore::metrics::{MetricsRegistry, raw};
34use tracing::{debug, info};
35
36use crate::MetricsUpdate;
37
38/// Identifies one observation: which reader produced it, and what it measures.
39///
40/// Static strings rather than an enum, so adding a metric touches only the sampler.
41pub type MetricKey = (&'static str, &'static str);
42
43/// The most recent observations, keyed by [`MetricKey`].
44///
45/// Published as a whole map rather than as independent cells so a reader always sees one
46/// self-consistent sample. Reading a set of independent atomics could mix two samples into a
47/// combination that never existed, for instance a peak below the current value it bounds.
48static OBSERVATIONS: Mutex<Option<BTreeMap<MetricKey, u64>>> = Mutex::new(None);
49
50/// Source names, as reported alongside each observation.
51pub mod source {
52    /// This process's cgroup v2 interface files.
53    pub const CGROUP: &str = "cgroup";
54    /// `getrusage(RUSAGE_SELF)`.
55    pub const RUSAGE: &str = "rusage";
56    /// `/proc/self/status`.
57    pub const PROC_STATUS: &str = "proc_status";
58    /// `statvfs` on the scratch directory's filesystem.
59    pub const STATVFS: &str = "statvfs";
60}
61
62/// Return the most recent resource usage observations of this process.
63///
64/// `None` until the sampler has taken its first sample, and forever in a process that never
65/// registered one. An observation the sampler could not read is absent from the map, never
66/// present as zero.
67pub fn observations() -> Option<BTreeMap<MetricKey, u64>> {
68    OBSERVATIONS.lock().expect("poisoned").clone()
69}
70
71/// Observations that get a peak folded in this process, because their source has no kernel-side
72/// high-water mark. Each is a maximum over samples and therefore a lower bound on the true peak.
73const DERIVED_PEAKS: &[(MetricKey, &str)] = &[
74    ((source::STATVFS, "fs_used"), "fs_used_peak"),
75    // Load-bearing only below the kernel version that provides `memory.swap.peak`.
76    ((source::PROC_STATUS, "vm_swap"), "vm_swap_peak"),
77    // A lower bound on the peak of the quantity the memory limiter enforces. No upper bound is
78    // available: the cgroup peaks describe a smaller quantity, excluding resident file-backed
79    // pages, and `ru_maxrss` is refreshed at kernel checkpoints and has been seen reading below
80    // the concurrent `vm_rss`.
81    ((source::PROC_STATUS, "heap"), "heap_peak"),
82];
83
84/// Sampler of resource usage observations, driven by the metrics update task.
85///
86/// Sampling happens here rather than in a compute logging operator because a saturated timely
87/// worker stops scheduling its logging operators during exactly the episodes we most want
88/// sampled. Folding the derived peaks here also keeps them alive across a teardown and rebuild of
89/// the logging dataflow, which an operator-local fold would lose.
90pub(crate) struct UsageMetrics {
91    /// This process's cgroup, if it has a v2 one with the memory controller enabled.
92    cgroup: Option<CgroupV2>,
93    /// Directory whose filesystem usage is tracked, if disk is in use.
94    disk_root: Option<PathBuf>,
95    /// Peaks folded here, for the sources listed in [`DERIVED_PEAKS`].
96    derived_peaks: BTreeMap<MetricKey, u64>,
97    gauges: raw::UIntGaugeVec,
98}
99
100impl UsageMetrics {
101    fn new(registry: &MetricsRegistry, disk_root: Option<PathBuf>) -> Self {
102        // Readings taken from the wrong cgroup look plausible rather than absent, and diagnosing
103        // that otherwise takes access to the container. Reported once, at registration.
104        let cgroup = CgroupV2::detect();
105        match &cgroup {
106            Some(cgroup) => info!(
107                dir = %cgroup.path().display(),
108                "reading resource usage from cgroup v2",
109            ),
110            None => info!("no cgroup v2 with a memory controller; cgroup usage unavailable"),
111        }
112
113        Self {
114            cgroup,
115            disk_root,
116            derived_peaks: BTreeMap::new(),
117            gauges: registry.register(metric!(
118                name: "mz_metrics_resource_usage",
119                help: "Resource usage observations, by source and metric.",
120                var_labels: ["source", "metric"],
121            )),
122        }
123    }
124
125    /// Read every source, without interpreting any of them.
126    fn sample(&self) -> BTreeMap<MetricKey, u64> {
127        let mut out = BTreeMap::new();
128        let mut put = |source: &'static str, metric: &'static str, value: Option<u64>| {
129            if let Some(value) = value {
130                out.insert((source, metric), value);
131            }
132        };
133
134        if let Some(cgroup) = &self.cgroup {
135            // `memory.peak` and `memory.swap.peak` are absent on kernels too old to provide them
136            // and read as `None` there. `memory.current` is the accounting that limit enforcement
137            // and the OOM killer act on, which is why it is worth reporting next to `vm_rss`.
138            let files: &[(&'static str, &str)] = &[
139                ("memory_current", "memory.current"),
140                ("memory_peak", "memory.peak"),
141                ("memory_max", "memory.max"),
142                ("swap_current", "memory.swap.current"),
143                ("swap_peak", "memory.swap.peak"),
144                ("swap_max", "memory.swap.max"),
145            ];
146            for &(metric, file) in files {
147                put(source::CGROUP, metric, cgroup.read_u64(file));
148            }
149
150            // `oom_kill` counts kills inside this cgroup and `max` counts times the limit was
151            // hit, which together answer whether a replica died of memory pressure.
152            let keyed: &[(&'static str, &str, &str)] = &[
153                ("anon", "memory.stat", "anon"),
154                ("file", "memory.stat", "file"),
155                ("shmem", "memory.stat", "shmem"),
156                // Pages held in memory with their swap slot still allocated. They are charged
157                // twice, once as `anon` here and once in `memory.swap.current`, and they are the
158                // whole of the difference between that and `proc_status vm_swap`.
159                ("swapcached", "memory.stat", "swapcached"),
160                ("kernel", "memory.stat", "kernel"),
161                ("slab", "memory.stat", "slab"),
162                ("sock", "memory.stat", "sock"),
163                ("events_max", "memory.events", "max"),
164                ("events_oom_kill", "memory.events", "oom_kill"),
165            ];
166            for &(metric, file, key) in keyed {
167                put(source::CGROUP, metric, cgroup.read_keyed_u64(file, key));
168            }
169        }
170
171        put(source::RUSAGE, "max_rss", max_rss_bytes());
172
173        match ProcStatus::from_proc() {
174            Ok(status) => {
175                put(source::PROC_STATUS, "vm_rss", Some(status.rss()));
176                put(source::PROC_STATUS, "vm_swap", Some(status.swap()));
177                // The quantity the memory limiter enforces against `--heap-limit`. Reported as
178                // its own observation because the kernel maintains no combined memory-plus-swap
179                // peak, so a peak of the sum has to be folded from samples of the sum. It is one
180                // source added to itself, not two sources fused.
181                put(source::PROC_STATUS, "heap", Some(status.heap()));
182                // Decomposes `vm_rss`. `rss_file` is the part charged to another cgroup, so it
183                // explains the gap between `vm_rss` and `cgroup memory_current`.
184                put(source::PROC_STATUS, "rss_anon", Some(status.rss_anon()));
185                put(source::PROC_STATUS, "rss_file", Some(status.rss_file()));
186                put(source::PROC_STATUS, "rss_shmem", Some(status.rss_shmem()));
187            }
188            Err(err) => debug!("failed to read /proc/self/status: {err}"),
189        }
190
191        if let Some(root) = self.disk_root.as_deref() {
192            // NOTE: filesystem-wide used bytes, not this process's usage. Named for what it is,
193            // since on a shared filesystem it counts writes this replica never made.
194            match disk_usage(root) {
195                Ok(bytes) => put(source::STATVFS, "fs_used", Some(bytes)),
196                Err(err) => debug!("statvfs on {} failed: {err}", root.display()),
197            }
198        }
199
200        out
201    }
202
203    /// Fold the derived peaks over `sample`, adding them to it.
204    fn fold_derived_peaks(&mut self, sample: &mut BTreeMap<MetricKey, u64>) {
205        for ((source, metric), peak_metric) in DERIVED_PEAKS {
206            let Some(&value) = sample.get(&(*source, *metric)) else {
207                continue;
208            };
209            let peak = self
210                .derived_peaks
211                .entry((source, peak_metric))
212                .and_modify(|peak| *peak = (*peak).max(value))
213                .or_insert(value);
214            sample.insert((source, peak_metric), *peak);
215        }
216    }
217}
218
219impl MetricsUpdate for UsageMetrics {
220    type Error = Infallible;
221    const NAME: &'static str = "usage";
222
223    fn update(&mut self) -> Result<(), Self::Error> {
224        let mut sample = self.sample();
225        self.fold_derived_peaks(&mut sample);
226
227        for ((source, metric), value) in &sample {
228            self.gauges.with_label_values(&[source, metric]).set(*value);
229        }
230
231        *OBSERVATIONS.lock().expect("poisoned") = Some(sample);
232
233        Ok(())
234    }
235}
236
237/// Register the resource usage sampler.
238///
239/// `disk_root` is a directory on the filesystem whose usage should be tracked, or `None` if this
240/// process does not use disk.
241pub(crate) fn register_metrics_into(
242    registry: &MetricsRegistry,
243    disk_root: Option<PathBuf>,
244) -> UsageMetrics {
245    UsageMetrics::new(registry, disk_root)
246}
247
248/// Return the used bytes of the filesystem containing `root`.
249///
250/// Callers decide how to report a failure. The sampler polls this on a short interval, so logging
251/// an error here would repeat for as long as the directory is unavailable.
252pub fn disk_usage(root: &Path) -> Result<u64, nix::Error> {
253    let stat = nix::sys::statvfs::statvfs(root)?;
254
255    // `fsblkcnt_t` is a `u32` on macOS but a `u64` on Linux.
256    #[allow(clippy::useless_conversion)]
257    let used_blocks = u64::from(stat.blocks() - stat.blocks_available());
258    let used_bytes = used_blocks * stat.fragment_size();
259
260    debug!("disk usage: {used_bytes}");
261
262    Ok(used_bytes)
263}
264
265/// Return this process's peak resident set size, in bytes.
266///
267/// This is the kernel's own high-water mark, so unlike a sampled maximum it cannot miss a
268/// short-lived spike.
269fn max_rss_bytes() -> Option<u64> {
270    match crate::rusage::max_rss_bytes() {
271        Ok(bytes) => u64::try_from(bytes).ok(),
272        Err(err) => {
273            debug!("getrusage failed: {err}");
274            None
275        }
276    }
277}
278
279/// Memory usage of the current process, read from `/proc/self/status`.
280///
281/// The `rss_*` fields decompose `vm_rss`. The decomposition is load-bearing rather than
282/// decorative: `rss_file` counts pages of file-backed mappings, most of it this binary's own text,
283/// and those pages are charged to whichever cgroup first faulted them in. On a Kubernetes node
284/// that is the runtime that unpacked the image, not the replica, so `vm_rss` runs a roughly
285/// constant amount above the replica's own cgroup charge.
286#[derive(Clone, Copy, Debug, Default)]
287pub struct ProcStatus {
288    /// Resident Set Size (RSS) in bytes.
289    pub vm_rss: usize,
290    /// Swap memory in bytes.
291    pub vm_swap: usize,
292    /// Resident anonymous memory in bytes.
293    pub rss_anon: usize,
294    /// Resident file-backed memory in bytes.
295    pub rss_file: usize,
296    /// Resident shared memory in bytes.
297    pub rss_shmem: usize,
298}
299
300impl ProcStatus {
301    /// Read a new `ProcStatus` from `/proc/self/status`.
302    ///
303    /// Fails on platforms without a Linux-style procfs.
304    pub fn from_proc() -> anyhow::Result<Self> {
305        let contents = std::fs::read_to_string("/proc/self/status")?;
306        let mut status = Self::default();
307
308        for line in contents.lines() {
309            let (field, target) = match line.split_once(':') {
310                Some(("VmRSS", rest)) => ("VmRSS", (&mut status.vm_rss, rest)),
311                Some(("VmSwap", rest)) => ("VmSwap", (&mut status.vm_swap, rest)),
312                Some(("RssAnon", rest)) => ("RssAnon", (&mut status.rss_anon, rest)),
313                Some(("RssFile", rest)) => ("RssFile", (&mut status.rss_file, rest)),
314                Some(("RssShmem", rest)) => ("RssShmem", (&mut status.rss_shmem, rest)),
315                _ => continue,
316            };
317            let (slot, rest) = target;
318            *slot = parse_kib(rest).with_context(|| format!("failed to parse {field}"))?;
319        }
320
321        Ok(status)
322    }
323
324    /// Memory (RAM) usage, in bytes.
325    pub fn rss(&self) -> u64 {
326        u64::cast_from(self.vm_rss)
327    }
328
329    /// Swap usage, in bytes.
330    pub fn swap(&self) -> u64 {
331        u64::cast_from(self.vm_swap)
332    }
333
334    /// Heap (RAM + swap) usage, in bytes.
335    pub fn heap(&self) -> u64 {
336        self.rss().saturating_add(self.swap())
337    }
338
339    /// Resident anonymous memory, in bytes.
340    pub fn rss_anon(&self) -> u64 {
341        u64::cast_from(self.rss_anon)
342    }
343
344    /// Resident file-backed memory, in bytes.
345    pub fn rss_file(&self) -> u64 {
346        u64::cast_from(self.rss_file)
347    }
348
349    /// Resident shared memory, in bytes.
350    pub fn rss_shmem(&self) -> u64 {
351        u64::cast_from(self.rss_shmem)
352    }
353}
354
355/// Parse the value part of a `/proc/self/status` line reporting a size in KiB, returning bytes.
356fn parse_kib(rest: &str) -> anyhow::Result<usize> {
357    let kib: usize = rest
358        .split_whitespace()
359        .next()
360        .ok_or_else(|| anyhow::anyhow!("missing value: {rest}"))?
361        .parse()?;
362    Ok(kib * 1024)
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn metrics_for_test() -> UsageMetrics {
370        UsageMetrics::new(&MetricsRegistry::new(), None)
371    }
372
373    /// Something must be measurable on the platforms we test on, otherwise the other tests here
374    /// assert nothing.
375    #[mz_ore::test]
376    fn sample_is_not_empty() {
377        let metrics = metrics_for_test();
378        assert!(!metrics.sample().is_empty());
379    }
380
381    /// A derived peak must rise with a higher observation and survive a lower one.
382    ///
383    /// Drives the fold over a synthetic sample rather than through `update`, so the assertion does
384    /// not depend on this machine's disk usage actually moving.
385    #[mz_ore::test]
386    fn derived_peaks_are_monotonic() {
387        let mut metrics = metrics_for_test();
388        let key = (source::STATVFS, "fs_used");
389        let peak_key = (source::STATVFS, "fs_used_peak");
390
391        let mut fold = |value| {
392            let mut sample = BTreeMap::from_iter([(key, value)]);
393            metrics.fold_derived_peaks(&mut sample);
394            sample[&peak_key]
395        };
396
397        assert_eq!(fold(100), 100, "first observation sets the peak");
398        assert_eq!(fold(200), 200, "a higher observation raises the peak");
399        assert_eq!(fold(50), 200, "a lower observation must not lower the peak");
400    }
401
402    /// `vm_rss` must decompose exactly into its three parts, since a caller comparing `rss_file`
403    /// against a cgroup charge relies on the decomposition being complete.
404    #[mz_ore::test]
405    #[cfg_attr(not(target_os = "linux"), ignore = "requires a Linux procfs")]
406    fn vm_rss_decomposes() {
407        let status = ProcStatus::from_proc().expect("procfs available");
408        assert_eq!(
409            status.rss(),
410            status.rss_anon() + status.rss_file() + status.rss_shmem(),
411            "vm_rss {} != anon {} + file {} + shmem {}",
412            status.rss(),
413            status.rss_anon(),
414            status.rss_file(),
415            status.rss_shmem(),
416        );
417    }
418
419    /// `heap` must be exactly the sum the memory limiter compares against its limit, since a
420    /// caller reading `heap_peak` to ask how close a replica came to a kill relies on it.
421    #[mz_ore::test]
422    #[cfg_attr(not(target_os = "linux"), ignore = "requires a Linux procfs")]
423    fn heap_is_rss_plus_swap() {
424        let metrics = metrics_for_test();
425        let sample = metrics.sample();
426
427        let get = |metric| sample[&(source::PROC_STATUS, metric)];
428        assert_eq!(get("heap"), get("vm_rss") + get("vm_swap"));
429    }
430
431    /// A source that cannot be read is absent, never zero.
432    #[mz_ore::test]
433    fn unmeasured_observation_is_absent() {
434        let metrics = metrics_for_test();
435        let sample = metrics.sample();
436
437        // No `disk_root` was configured, so nothing from `statvfs` may appear.
438        assert!(sample.keys().all(|(source, _)| *source != source::STATVFS));
439    }
440
441    /// A derived peak is only published for a source that was actually read.
442    #[mz_ore::test]
443    fn derived_peak_needs_an_observation() {
444        let mut metrics = metrics_for_test();
445        let mut sample = BTreeMap::new();
446        metrics.fold_derived_peaks(&mut sample);
447        assert!(sample.is_empty());
448    }
449}