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}