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}