Skip to main content

somatize_fabric_wire/
machine.rs

1//! What a machine says about itself. The one thing no record can derive.
2//!
3//! Everything else about a worker is already written down by whoever asked it
4//! to do something. What is **not** in there is the machine — how loaded it is,
5//! how much memory is left, how long it has been up — which is the half of *see
6//! the health of the workers* that a scan cannot answer.
7//!
8//! A load average is not a fact about a graph, so putting it in
9//! [`Fact`](somatize_core::Fact) as its own variant would be the engine
10//! learning what a machine is. The vocabulary lives **here**, where a host is
11//! already a thing, and it crosses as `(kind, pairs)` inside
12//! [`Fact::Said`](somatize_core::Fact::Said).
13//!
14//! Which costs nothing on the wire: `Answer::Saw` already carries a `Fact`, and
15//! the engine already wraps whatever arrives in [`Fact::Elsewhere`] — so this
16//! **arrives saying which host it came from** without one line attributing it.
17//!
18//! It is read and never judged. No thresholds here or near here: whether 0.9
19//! busy is bad is somebody's opinion at a bound, and those live in `health/`.
20
21use somatize_core::Fact;
22use std::time::Duration;
23
24/// What one machine looks like right now.
25///
26/// A struct and not an enum: these are not alternatives but a snapshot, every
27/// one of them measured at the same instant. `None` is **nobody measured it**
28/// and never zero — a kernel that keeps no load average is not an idle machine.
29#[derive(Debug, Clone, Default, PartialEq)]
30pub struct Machine {
31    /// How long this worker process has been up.
32    ///
33    /// Its own monotonic clock and never a wall clock: two machines' wall
34    /// clocks disagree by minutes on a cluster as a matter of course. What the
35    /// reader gets is a duration, and *when* is stamped by whoever writes it.
36    pub up: Duration,
37    /// The run queue against the number of cores, so two machines of different
38    /// sizes can be compared.
39    ///
40    /// The ratio and not the raw load, because a load of 8 is a busy laptop and
41    /// an idle compute node. [`Machine::cores`] is beside it for whoever wants
42    /// to undo the division.
43    pub busy: Option<f64>,
44    /// How many cores it divided by.
45    pub cores: Option<usize>,
46    /// What fraction of memory is in use.
47    ///
48    /// Against what the kernel says is **available** rather than what is free:
49    /// page cache is not memory anybody is short of, and counting it as used is
50    /// how a perfectly healthy machine reads as full.
51    pub memory: Option<f64>,
52    /// How many slices this worker has run since it started.
53    pub served: u64,
54    /// What this machine calls **itself**: its hostname and this process.
55    ///
56    /// Not the name the graph gave it. **A worker does not know that name** —
57    /// `w1` is the client's word, which is why the client attributes a fact. A
58    /// reading written to a store has no client to attribute it, so it is filed
59    /// under something the worker can know on its own, and whoever reads joins
60    /// the two by seeing the same `id` on a reading that **did** come down a
61    /// wire.
62    pub id: String,
63}
64
65impl Machine {
66    /// A reading of the machine this is running on.
67    ///
68    /// Everything comes from `/proc`, which is Linux. Elsewhere the fields are
69    /// `None` and say so by being `None` — a worker on a laptop still reports
70    /// its uptime and how much it has served.
71    pub fn here(up: Duration, served: u64) -> Self {
72        let cores = std::thread::available_parallelism()
73            .map(|one| one.get())
74            .ok();
75        Self {
76            up,
77            busy: load().zip(cores).map(|(load, cores)| load / cores as f64),
78            cores,
79            memory: memory(),
80            served,
81            id: mine(),
82        }
83    }
84
85    /// This reading as the fact that crosses, already flat.
86    ///
87    /// Named `machine`, which is what it will be written down as and what a
88    /// reader filters on. A field that was not measured is **absent** rather
89    /// than empty: a reader that finds no `busy` has to be able to tell *this
90    /// kernel does not say* from *nothing is running*.
91    pub fn said(&self) -> Fact {
92        let mut pairs = vec![
93            ("up_us".into(), self.up.as_micros().to_string()),
94            ("served".into(), self.served.to_string()),
95        ];
96        if !self.id.is_empty() {
97            pairs.push(("id".into(), self.id.clone()));
98        }
99        for (name, what) in [("busy", self.busy), ("memory", self.memory)] {
100            if let Some(one) = what.filter(|one| one.is_finite()) {
101                pairs.push((name.into(), format!("{one:.4}")));
102            }
103        }
104        if let Some(cores) = self.cores {
105            pairs.push(("cores".into(), cores.to_string()));
106        }
107        Fact::Said {
108            kind: "machine".into(),
109            pairs,
110        }
111    }
112
113    /// A reading, back out of the pairs [`said`](Self::said) wrote.
114    ///
115    /// Here and not wherever it is read: two halves of one format in two crates
116    /// drift, and the day a field is added the writer and the reader would each
117    /// look right and disagree about what a reading is.
118    ///
119    /// **What is not there is `None`**, which is the rule the other half writes
120    /// by. A field that *is* there and will not parse is treated the same way,
121    /// on purpose — refusing all of it would throw away the uptime for the sake
122    /// of the load average.
123    pub fn read(pairs: &[(String, String)]) -> Self {
124        Self {
125            // Absent is zero here and only here: `up` and `served` are what
126            // the process itself counted and not measurements of a kernel, so a
127            // reading without them is one this version cannot read rather than
128            // a machine that did not say.
129            up: Duration::from_micros(parsed(pairs, "up_us").unwrap_or(0)),
130            busy: parsed(pairs, "busy"),
131            cores: parsed(pairs, "cores"),
132            memory: parsed(pairs, "memory"),
133            served: parsed(pairs, "served").unwrap_or(0),
134            id: beside(pairs, "id").unwrap_or_default().to_string(),
135        }
136    }
137}
138
139/// One field of a reading, if it is there.
140fn beside<'p>(pairs: &'p [(String, String)], what: &str) -> Option<&'p str> {
141    pairs
142        .iter()
143        .find(|(name, _)| name == what)
144        .map(|(_, said)| said.as_str())
145}
146
147/// The same, read as whatever it should be. Anything that will not parse is
148/// nobody having said it, which is [`Machine::read`]'s whole rule.
149fn parsed<T: std::str::FromStr>(pairs: &[(String, String)], what: &str) -> Option<T> {
150    beside(pairs, what)?.parse().ok()
151}
152
153/// Where a reading of this machine is filed in a store.
154///
155/// **One name per machine and rewritten every time**, not one per reading. That
156/// buys two things: a store that does not grow while a worker sits there, and
157/// liveness for free — the store stamps every write, so a reading that has not
158/// moved is a machine that has stopped, found out by a scan with no fetches.
159pub fn filed(id: &str) -> String {
160    format!("machine/{id}")
161}
162
163/// What this machine calls itself: its hostname, and which process on it.
164///
165/// The pid matters: two workers on one box are two workers, and filing both
166/// under the hostname would have the second quietly overwriting the first.
167fn mine() -> String {
168    let host = std::fs::read_to_string("/proc/sys/kernel/hostname")
169        .ok()
170        .map(|one| one.trim().to_string())
171        .filter(|one| !one.is_empty())
172        .unwrap_or_else(|| "unknown".into());
173    format!("{host}-{}", std::process::id())
174}
175
176/// The one-minute run queue, out of `/proc/loadavg`.
177fn load() -> Option<f64> {
178    std::fs::read_to_string("/proc/loadavg")
179        .ok()?
180        .split_whitespace()
181        .next()?
182        .parse()
183        .ok()
184}
185
186/// What fraction of memory is in use, out of `/proc/meminfo`.
187fn memory() -> Option<f64> {
188    let said = std::fs::read_to_string("/proc/meminfo").ok()?;
189    let mut total: Option<f64> = None;
190    let mut free: Option<f64> = None;
191    for line in said.lines() {
192        // A line this does not understand is skipped and not fatal: losing the
193        // whole reading because one kernel grew a field is the kind of thing
194        // that goes wrong on the machine you cannot log into.
195        let Some((name, rest)) = line.split_once(':') else {
196            continue;
197        };
198        let value = rest
199            .split_whitespace()
200            .next()
201            .and_then(|one| one.parse().ok());
202        match name {
203            "MemTotal" => total = value,
204            "MemAvailable" => free = value,
205            _ => {}
206        }
207    }
208    let (total, free) = (total?, free?);
209    (total > 0.0).then(|| (1.0 - free / total).clamp(0.0, 1.0))
210}