Skip to main content

zenkey_fleet/judge/
self_stats.rs

1//! A producer's declared `[budget]` against what its health document says
2//! it costs (#391): the agent that grew from 110 MB to 355 MB on a 1 GB
3//! host, was OOM-killed, and reported `Healthy` throughout.
4//!
5//! RFC 08 §2 (v1.32) lets a producer or service file declare what it may
6//! cost — `rss_mb`, and a bound per named table — and RFC 04 §1.2 names the
7//! object on the health document that reports the same quantities,
8//! `self_stats { rss_bytes, budget_bytes?, tables?: [{name, entries,
9//! bytes}] }`. RFC 13 §3 says what a judge owes the declaration:
10//!
11//! - **Not asked** when the slice declares no `[budget]`. Nothing here runs
12//!   for such a slice; the doctor's deep phase never fetches its health.
13//! - **Unobservable** when no health document carrying `self_stats` was
14//!   seen — and the reason reads *"this producer does not say how big it
15//!   is"*, because that is itself the finding an operator wants. One
16//!   Warning per origin, never folded into a pass. A declared table the
17//!   document does not report is unobservable for that table, the same way.
18//! - **Established(no)** — `budget-exceeded`, severity Error — per origin,
19//!   when `rss_bytes` exceeds `rss_mb · 2^20` or a named table exceeds
20//!   `max_entries` or `max_bytes`.
21//! - **Established(yes)** otherwise, for the sample read: no finding.
22//!
23//! A fetch of health documents costs the data plane, so it is asked for
24//! explicitly — `doctor --deep` — never folded into an ambient render (RFC
25//! 13 §3's frugality note). Pure, the house pattern of
26//! [`crate::judge::field`] and [`crate::judge::budget`] (which is the
27//! *cardinality* budget — hence this module's name): nothing here takes a
28//! session, so the same reading judges a recorded health document.
29
30use serde_json::Value;
31use zenkey::RegistrySlice;
32
33use crate::report::{CheckId, DoctorFinding, DoctorSeverity};
34
35/// What a health document says about its producer's size (RFC 04 §1.2,
36/// `self_stats`). Every field is optional on the wire and stays optional
37/// here: an absent number is *unobservable*, which is a different answer
38/// from zero.
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct SelfStats {
41    /// The resident set, in bytes.
42    pub rss_bytes: Option<u64>,
43    /// The budget the producer believes it runs under, in bytes — absent
44    /// when none is configured. Reported, not judged: the registry's
45    /// `rss_mb` is the declaration, and this is the producer's echo of it.
46    pub budget_bytes: Option<u64>,
47    /// Each bounded structure the producer keeps, with its occupancy.
48    pub tables: Vec<TableStats>,
49}
50
51/// One `self_stats.tables[]` row.
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct TableStats {
54    pub name: String,
55    pub entries: Option<u64>,
56    pub bytes: Option<u64>,
57}
58
59/// Read `self_stats` off a health document.
60///
61/// `None` when the document carries no `self_stats`, or carries one that is
62/// not an object — both are "this producer does not say how big it is".
63/// Within the object every field is read when it is a non-negative integer
64/// and left `None` otherwise; a `tables` row without a `name` is dropped,
65/// because nothing could be matched against it.
66pub fn read_self_stats(doc: &Value) -> Option<SelfStats> {
67    let stats = doc.get("self_stats")?.as_object()?;
68    let count = |v: Option<&Value>| v.and_then(Value::as_u64);
69    let tables = stats
70        .get("tables")
71        .and_then(Value::as_array)
72        .into_iter()
73        .flatten()
74        .filter_map(|row| {
75            Some(TableStats {
76                name: row.get("name")?.as_str()?.to_string(),
77                entries: count(row.get("entries")),
78                bytes: count(row.get("bytes")),
79            })
80        })
81        .collect();
82    Some(SelfStats {
83        rss_bytes: count(stats.get("rss_bytes")),
84        budget_bytes: count(stats.get("budget_bytes")),
85        tables,
86    })
87}
88
89/// The `budget-exceeded` findings for one slice's health documents.
90///
91/// `answers` is one entry per origin that answered the health GET: `Some`
92/// when its document carried `self_stats`, `None` when it did not (or was
93/// not a document at all). `asked` is how many origins the roster showed
94/// running this producer, so silence can be stated with its scope (RFC 05
95/// §3.1). A slice without a `[budget]` yields nothing — not asked.
96pub fn judge_self_stats(
97    slice: &RegistrySlice,
98    answers: &[(String, Option<SelfStats>)],
99    asked: usize,
100) -> Vec<DoctorFinding> {
101    let Some(budget) = &slice.budget else {
102        return Vec::new();
103    };
104    let mut out = Vec::new();
105    let finding = |severity, subject: String, evidence: String, citation: &str| DoctorFinding {
106        severity,
107        check: CheckId::BudgetExceeded,
108        subject,
109        evidence,
110        citation: Some(citation.to_string()),
111    };
112
113    if answers.is_empty() {
114        out.push(finding(
115            DoctorSeverity::Warning,
116            slice.name.clone(),
117            format!(
118                "no health document answered for {} (asked {asked} origin(s)) — its \
119                 declared budget is unobservable this run",
120                slice.name
121            ),
122            "RFC 13 §3",
123        ));
124        return out;
125    }
126
127    for (origin, stats) in answers {
128        let subject = format!("{origin}/{}", slice.name);
129        let Some(stats) = stats else {
130            out.push(finding(
131                DoctorSeverity::Warning,
132                subject,
133                "unobservable: this producer does not say how big it is (no `self_stats` \
134                 on its health document)"
135                    .to_string(),
136                "RFC 04 §1.2",
137            ));
138            continue;
139        };
140
141        if let Some(rss_mb) = budget.rss_mb {
142            match stats.rss_bytes {
143                Some(rss_bytes) if rss_bytes > mib_to_bytes(rss_mb) => out.push(finding(
144                    DoctorSeverity::Error,
145                    subject.clone(),
146                    format!(
147                        "resident set {} exceeds the declared budget of {rss_mb} MiB \
148                         (rss_bytes = {rss_bytes})",
149                        mib(rss_bytes)
150                    ),
151                    "RFC 08 §2",
152                )),
153                Some(_) => {}
154                None => out.push(finding(
155                    DoctorSeverity::Warning,
156                    subject.clone(),
157                    format!(
158                        "unobservable: `self_stats` carries no `rss_bytes` to judge the \
159                         declared {rss_mb} MiB against"
160                    ),
161                    "RFC 04 §1.2",
162                )),
163            }
164        }
165
166        for table in &budget.tables {
167            let Some(seen) = stats.tables.iter().find(|t| t.name == table.name) else {
168                out.push(finding(
169                    DoctorSeverity::Warning,
170                    subject.clone(),
171                    format!(
172                        "unobservable: table `{}` is budgeted but `self_stats.tables` does \
173                         not report it",
174                        table.name
175                    ),
176                    "RFC 04 §1.2",
177                ));
178                continue;
179            };
180            for (what, bound, observed) in [
181                ("entries", table.max_entries, seen.entries),
182                ("bytes", table.max_bytes, seen.bytes),
183            ] {
184                let Some(bound) = bound else {
185                    continue;
186                };
187                // A negative bound cannot come from a linted registry (#313)
188                // and is unsatisfiable from a foreign one: judged as zero.
189                let bound = u64::try_from(bound).unwrap_or(0);
190                if let Some(observed) = observed
191                    && observed > bound
192                {
193                    out.push(finding(
194                        DoctorSeverity::Error,
195                        subject.clone(),
196                        format!(
197                            "table `{}` holds {observed} {what}, over its declared \
198                             max_{what} of {bound}",
199                            table.name
200                        ),
201                        "RFC 08 §2",
202                    ));
203                }
204            }
205        }
206    }
207    out
208}
209
210/// `rss_mb · 2^20`, saturating — a bound too large to spell in bytes is
211/// one nothing exceeds. A negative bound cannot come from a linted registry
212/// (#313); from a foreign one it is judged as zero.
213fn mib_to_bytes(mib: i64) -> u64 {
214    u64::try_from(mib).unwrap_or(0).saturating_mul(1 << 20)
215}
216
217/// Bytes rendered in MiB, one decimal, so the finding reads in the unit
218/// the budget was declared in.
219fn mib(bytes: u64) -> String {
220    format!("{:.1} MiB", bytes as f64 / (1u64 << 20) as f64)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use serde_json::json;
227    use zenkey::slice::{BudgetDecl, TableBudget};
228
229    fn budgeted() -> RegistrySlice {
230        let mut slice = RegistrySlice::new("1.0", "t", "demo");
231        let mut budget = BudgetDecl::new();
232        budget.rss_mb = Some(64);
233        let mut flows = TableBudget::new("flows");
234        flows.max_entries = Some(65536);
235        flows.max_bytes = Some(16_777_216);
236        budget.tables.push(flows);
237        slice.budget = Some(budget);
238        slice
239    }
240
241    fn errors(findings: &[DoctorFinding]) -> Vec<&DoctorFinding> {
242        findings
243            .iter()
244            .filter(|f| f.severity == DoctorSeverity::Error)
245            .collect()
246    }
247
248    /// `self_stats` is read when it is an object and every field is
249    /// optional; absent or malformed, the answer is `None` — unobservable,
250    /// not zero.
251    #[test]
252    fn self_stats_is_read_when_present_and_none_otherwise() {
253        assert_eq!(read_self_stats(&json!({"status": "Healthy"})), None);
254        assert_eq!(read_self_stats(&json!({"self_stats": 12})), None);
255        assert_eq!(read_self_stats(&json!("Healthy")), None);
256        let stats = read_self_stats(&json!({
257            "self_stats": {
258                "rss_bytes": 123_456_789u64,
259                "tables": [
260                    {"name": "flows", "entries": 4096},
261                    {"entries": 1},
262                    {"name": "names", "bytes": -5}
263                ]
264            }
265        }))
266        .expect("an object is read");
267        assert_eq!(stats.rss_bytes, Some(123_456_789));
268        assert_eq!(stats.budget_bytes, None);
269        assert_eq!(stats.tables.len(), 2, "a nameless row is dropped");
270        assert_eq!(stats.tables[0].entries, Some(4096));
271        assert_eq!(stats.tables[0].bytes, None);
272        assert_eq!(
273            stats.tables[1].bytes, None,
274            "a negative count is not a count"
275        );
276    }
277
278    /// The bound is exact: 64 MiB is within a 64 MiB budget, one byte more
279    /// is over it — and the finding says both numbers in MiB.
280    #[test]
281    fn rss_is_judged_against_rss_mb_exactly() {
282        let slice = budgeted();
283        let at = SelfStats {
284            rss_bytes: Some(64 << 20),
285            ..Default::default()
286        };
287        let over = SelfStats {
288            rss_bytes: Some((64 << 20) + 1),
289            ..Default::default()
290        };
291        let stats = |s: SelfStats| {
292            let mut s = s;
293            s.tables.push(TableStats {
294                name: "flows".into(),
295                entries: Some(1),
296                bytes: Some(1),
297            });
298            s
299        };
300        let f = judge_self_stats(&slice, &[("h-1".into(), Some(stats(at)))], 1);
301        assert!(f.is_empty(), "at the bound is within it: {f:?}");
302        let f = judge_self_stats(&slice, &[("h-1".into(), Some(stats(over)))], 1);
303        let e = errors(&f);
304        assert_eq!(e.len(), 1, "{f:?}");
305        assert_eq!(e[0].check, CheckId::BudgetExceeded);
306        assert_eq!(e[0].subject, "h-1/demo");
307        assert!(e[0].evidence.contains("64.0 MiB"), "{}", e[0].evidence);
308        assert!(e[0].evidence.contains("64 MiB"), "{}", e[0].evidence);
309        assert_eq!(e[0].citation.as_deref(), Some("RFC 08 §2"));
310    }
311
312    /// Tables match by name: a budgeted table over either bound is an Error
313    /// naming the table and the numbers; one the document does not report
314    /// is unobservable, said so; one the budget never named is nobody's
315    /// business.
316    #[test]
317    fn tables_are_matched_by_name_and_judged_per_bound() {
318        let slice = budgeted();
319        let stats = SelfStats {
320            rss_bytes: Some(1 << 20),
321            tables: vec![
322                TableStats {
323                    name: "flows".into(),
324                    entries: Some(70_000),
325                    bytes: Some(16_777_216),
326                },
327                TableStats {
328                    name: "unbudgeted".into(),
329                    entries: Some(u64::MAX),
330                    bytes: None,
331                },
332            ],
333            ..Default::default()
334        };
335        let f = judge_self_stats(&slice, &[("h-1".into(), Some(stats))], 1);
336        assert_eq!(f.len(), 1, "{f:?}");
337        assert_eq!(f[0].severity, DoctorSeverity::Error);
338        assert!(f[0].evidence.contains("`flows`"), "{}", f[0].evidence);
339        assert!(f[0].evidence.contains("70000 entries"), "{}", f[0].evidence);
340        assert!(f[0].evidence.contains("65536"), "{}", f[0].evidence);
341
342        let missing = SelfStats {
343            rss_bytes: Some(1 << 20),
344            ..Default::default()
345        };
346        let f = judge_self_stats(&slice, &[("h-1".into(), Some(missing))], 1);
347        assert_eq!(f.len(), 1, "{f:?}");
348        assert_eq!(f[0].severity, DoctorSeverity::Warning);
349        assert!(f[0].evidence.contains("unobservable"), "{}", f[0].evidence);
350        assert!(f[0].evidence.contains("`flows`"), "{}", f[0].evidence);
351        assert_eq!(f[0].citation.as_deref(), Some("RFC 04 §1.2"));
352    }
353
354    /// Per origin, never pooled: one host over and one under is one finding,
355    /// on the host that is over.
356    #[test]
357    fn origins_are_judged_one_by_one() {
358        let mut slice = budgeted();
359        slice.budget.as_mut().unwrap().tables.clear();
360        let f = judge_self_stats(
361            &slice,
362            &[
363                (
364                    "h-1".into(),
365                    Some(SelfStats {
366                        rss_bytes: Some(100 << 20),
367                        ..Default::default()
368                    }),
369                ),
370                (
371                    "h-2".into(),
372                    Some(SelfStats {
373                        rss_bytes: Some(10 << 20),
374                        ..Default::default()
375                    }),
376                ),
377            ],
378            2,
379        );
380        assert_eq!(f.len(), 1, "{f:?}");
381        assert_eq!(f[0].subject, "h-1/demo");
382    }
383
384    /// A document without `self_stats` is the stated unobservability, with
385    /// the reason an operator wants; no document at all is silence with its
386    /// scope; and a slice without a `[budget]` is never asked.
387    #[test]
388    fn unobservable_and_not_asked_are_said_not_folded() {
389        let slice = budgeted();
390        let f = judge_self_stats(&slice, &[("h-1".into(), None)], 1);
391        assert_eq!(f.len(), 1, "{f:?}");
392        assert_eq!(f[0].severity, DoctorSeverity::Warning);
393        assert!(
394            f[0].evidence.contains("does not say how big it is"),
395            "{}",
396            f[0].evidence
397        );
398
399        let f = judge_self_stats(&slice, &[], 3);
400        assert_eq!(f.len(), 1, "{f:?}");
401        assert_eq!(f[0].severity, DoctorSeverity::Warning);
402        assert!(
403            f[0].evidence.contains("asked 3 origin(s)"),
404            "{}",
405            f[0].evidence
406        );
407
408        let unbudgeted = RegistrySlice::new("1.0", "t", "demo");
409        assert!(judge_self_stats(&unbudgeted, &[("h-1".into(), None)], 1).is_empty());
410        assert!(judge_self_stats(&unbudgeted, &[], 0).is_empty());
411    }
412}