1use serde_json::Value;
31use zenkey::RegistrySlice;
32
33use crate::report::{CheckId, DoctorFinding, DoctorSeverity};
34
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct SelfStats {
41 pub rss_bytes: Option<u64>,
43 pub budget_bytes: Option<u64>,
47 pub tables: Vec<TableStats>,
49}
50
51#[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
59pub 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
89pub 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 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
210fn mib_to_bytes(mib: i64) -> u64 {
214 u64::try_from(mib).unwrap_or(0).saturating_mul(1 << 20)
215}
216
217fn 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 #[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 #[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 #[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 #[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 #[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}