1use {
23 crate::{
24 contract::{BudgetError, Contract, FORMAT_VERSION, Mode, contract_digest},
25 num::to_f64,
26 },
27 serde::{Deserialize, Serialize},
28 std::{
29 collections::BTreeMap,
30 fs,
31 io::ErrorKind,
32 path::{Path, PathBuf},
33 str::FromStr,
34 },
35};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Side {
41 Reference,
43 Candidate,
45}
46
47impl Side {
48 pub const BOTH: [Self; 2] = [Self::Reference, Self::Candidate];
50
51 #[must_use]
53 pub fn name(self) -> &'static str {
54 match self {
55 Self::Reference => "reference",
56 Self::Candidate => "candidate",
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct Subject {
65 pub label: String,
67 pub revision: String,
69 pub image: String,
71 pub image_digest: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Host {
79 pub cpu_model: String,
81 pub logical_cpus: u32,
83 pub memory_bytes: u64,
85 pub disk_available_bytes: u64,
87 pub kernel: String,
89 pub clock_ticks_per_second: u32,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct Measurement {
97 pub sent: u64,
99 pub consumed: u64,
101 pub duplicates: u64,
103 pub errors: u64,
105 pub seconds: f64,
107 pub records_per_second: f64,
109 pub mib_per_second: f64,
111 pub latency_ms_p50: f64,
113 pub latency_ms_p95: f64,
115 pub latency_ms_p99: f64,
117 pub cpu_seconds: f64,
119 pub peak_rss_bytes: u64,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(tag = "status", rename_all = "snake_case")]
126pub enum TrialOutcome {
127 Completed(Measurement),
129 Failed {
131 reason: String,
133 },
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct Trial {
140 pub cell: String,
142 pub round: usize,
144 pub side: Side,
146 pub order: usize,
148 pub outcome: TrialOutcome,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct PerformanceRecord {
156 pub format_version: u32,
158 pub contract_name: String,
160 pub contract_sha256: String,
162 pub mode: Mode,
164 pub reference: Subject,
166 pub candidate: Subject,
168 pub client_image: String,
170 pub client_image_digest: String,
172 pub host: Host,
174 pub started_at: String,
176 pub settings: BTreeMap<String, String>,
178 pub trials: Vec<Trial>,
180}
181
182impl PerformanceRecord {
183 pub fn from_json(bytes: &[u8]) -> Result<Self, BudgetError> {
190 let record: Self = serde_json::from_slice(bytes)?;
191 if record.format_version != FORMAT_VERSION {
192 return Err(BudgetError::FormatVersion {
193 what: "record",
194 found: record.format_version,
195 });
196 }
197 Ok(record)
198 }
199}
200
201fn artifact_error(path: &str, reason: impl Into<String>) -> BudgetError {
202 BudgetError::InvalidArtifact {
203 path: path.to_owned(),
204 reason: reason.into(),
205 }
206}
207
208pub fn parse_env(path: &str, text: &str) -> Result<BTreeMap<String, String>, BudgetError> {
216 let mut values = BTreeMap::new();
217 for line in text.lines() {
218 let line = line.trim();
219 if line.is_empty() || line.starts_with('#') {
220 continue;
221 }
222 let Some((key, value)) = line.split_once('=') else {
223 return Err(artifact_error(path, format!("line {line:?} has no '='")));
224 };
225 if values
226 .insert(key.trim().to_owned(), value.trim().to_owned())
227 .is_some()
228 {
229 return Err(artifact_error(path, format!("key {key} appears twice")));
230 }
231 }
232 Ok(values)
233}
234
235fn take(
236 path: &str,
237 values: &mut BTreeMap<String, String>,
238 key: &str,
239) -> Result<String, BudgetError> {
240 values
241 .remove(key)
242 .filter(|value| !value.is_empty())
243 .ok_or_else(|| artifact_error(path, format!("missing {key}")))
244}
245
246fn take_number<T: FromStr>(
247 path: &str,
248 values: &mut BTreeMap<String, String>,
249 key: &str,
250) -> Result<T, BudgetError> {
251 let raw = take(path, values, key)?;
252 raw.parse()
253 .map_err(|_| artifact_error(path, format!("{key}={raw} is not a number")))
254}
255
256pub fn parse_host(text: &str) -> Result<Host, BudgetError> {
262 let path = "host.env";
263 let mut values = parse_env(path, text)?;
264 let host = Host {
265 cpu_model: take(path, &mut values, "cpu_model")?,
266 logical_cpus: take_number(path, &mut values, "logical_cpus")?,
267 memory_bytes: take_number(path, &mut values, "memory_bytes")?,
268 disk_available_bytes: take_number(path, &mut values, "disk_available_bytes")?,
269 kernel: take(path, &mut values, "kernel")?,
270 clock_ticks_per_second: take_number(path, &mut values, "clock_ticks_per_second")?,
271 };
272 if host.clock_ticks_per_second == 0 {
273 return Err(artifact_error(
274 path,
275 "clock_ticks_per_second must be positive",
276 ));
277 }
278 Ok(host)
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct ProcessSample {
284 pub cpu_ticks: u64,
286 pub process_start_ticks: u64,
289 pub rss_hwm_kib: u64,
291}
292
293pub fn parse_snapshot(
304 path: &str,
305 text: &str,
306) -> Result<BTreeMap<String, ProcessSample>, BudgetError> {
307 let mut pods: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
308 let mut current: Option<String> = None;
309 for line in text.lines() {
310 let line = line.trim();
311 if line.is_empty() {
312 continue;
313 }
314 let Some((key, value)) = line.split_once('=') else {
315 return Err(artifact_error(path, format!("line {line:?} has no '='")));
316 };
317 if key == "pod" {
318 if pods.insert(value.to_owned(), BTreeMap::new()).is_some() {
319 return Err(artifact_error(path, format!("pod {value} appears twice")));
320 }
321 current = Some(value.to_owned());
322 continue;
323 }
324 let Some(pod) = ¤t else {
325 return Err(artifact_error(
326 path,
327 format!("{key} precedes the first pod="),
328 ));
329 };
330 if let Some(block) = pods.get_mut(pod) {
331 block.insert(key.to_owned(), value.to_owned());
332 }
333 }
334 if pods.is_empty() {
335 return Err(artifact_error(path, "no pod in the snapshot"));
336 }
337 pods.into_iter()
338 .map(|(pod, mut block)| {
339 let sample = ProcessSample {
340 cpu_ticks: take_number(path, &mut block, "cpu_ticks")?,
341 process_start_ticks: take_number(path, &mut block, "process_start_ticks")?,
342 rss_hwm_kib: take_number(path, &mut block, "rss_hwm_kib")?,
343 };
344 Ok((pod, sample))
345 })
346 .collect()
347}
348
349pub fn resource_delta(
359 before: &BTreeMap<String, ProcessSample>,
360 after: &BTreeMap<String, ProcessSample>,
361 clock_ticks_per_second: u32,
362) -> Result<(f64, u64), String> {
363 if before.keys().ne(after.keys()) {
364 return Err("the broker pod set changed during the trial".to_owned());
365 }
366 let mut ticks = 0_u64;
367 let mut rss_bytes = 0_u64;
368 for (pod, start) in before {
369 let end = after[pod];
370 if end.process_start_ticks != start.process_start_ticks {
371 return Err(format!(
372 "{pod}'s broker process started again during the trial"
373 ));
374 }
375 let Some(used) = end.cpu_ticks.checked_sub(start.cpu_ticks) else {
376 return Err(format!(
377 "{pod}'s CPU counter went backwards: its broker process restarted"
378 ));
379 };
380 ticks = ticks.saturating_add(used);
381 rss_bytes = rss_bytes.saturating_add(end.rss_hwm_kib.saturating_mul(1024));
382 }
383 Ok((to_f64(ticks) / f64::from(clock_ticks_per_second), rss_bytes))
384}
385
386#[derive(Debug, Deserialize)]
388struct WorkloadResult {
389 sent: u64,
390 consumed: u64,
391 duplicates: u64,
392 errors: u64,
393 seconds: f64,
394 records_per_second: f64,
395 mib_per_second: f64,
396 latency_ms_p50: f64,
397 latency_ms_p95: f64,
398 latency_ms_p99: f64,
399}
400
401fn last_log_line(log: &str) -> Option<&str> {
404 log.lines().map(str::trim).rfind(|line| !line.is_empty())
405}
406
407fn read_optional(path: &Path) -> Result<Option<String>, BudgetError> {
408 match fs::read_to_string(path) {
409 Ok(text) => Ok(Some(text)),
410 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
411 Err(error) => Err(error.into()),
412 }
413}
414
415fn trial_outcome(
417 directory: &Path,
418 relative: &str,
419 clock_ticks_per_second: u32,
420) -> Result<TrialOutcome, BudgetError> {
421 let failed = |reason: String| Ok(TrialOutcome::Failed { reason });
422 let Some(workload) = read_optional(&directory.join("workload.json"))? else {
423 let log = read_optional(&directory.join("workload.log"))?.unwrap_or_default();
424 return failed(last_log_line(&log).map_or_else(
425 || "the workload reported no result".to_owned(),
426 |line| format!("the workload reported no result: {line}"),
427 ));
428 };
429 let Ok(result) = serde_json::from_str::<WorkloadResult>(&workload) else {
430 return failed("workload.json is not a workload result".to_owned());
431 };
432 let snapshot = |name: &str| -> Result<Option<BTreeMap<String, ProcessSample>>, BudgetError> {
433 read_optional(&directory.join(name))?
434 .map(|text| parse_snapshot(&format!("{relative}/{name}"), &text))
435 .transpose()
436 };
437 let (Some(before), Some(after)) = (
438 snapshot("resources-before.txt")?,
439 snapshot("resources-after.txt")?,
440 ) else {
441 return failed("a resource snapshot is missing".to_owned());
442 };
443 let (cpu_seconds, peak_rss_bytes) =
444 match resource_delta(&before, &after, clock_ticks_per_second) {
445 Ok(delta) => delta,
446 Err(reason) => return failed(reason),
447 };
448 Ok(TrialOutcome::Completed(Measurement {
449 sent: result.sent,
450 consumed: result.consumed,
451 duplicates: result.duplicates,
452 errors: result.errors,
453 seconds: result.seconds,
454 records_per_second: result.records_per_second,
455 mib_per_second: result.mib_per_second,
456 latency_ms_p50: result.latency_ms_p50,
457 latency_ms_p95: result.latency_ms_p95,
458 latency_ms_p99: result.latency_ms_p99,
459 cpu_seconds,
460 peak_rss_bytes,
461 }))
462}
463
464fn parse_mode(path: &str, raw: &str) -> Result<Mode, BudgetError> {
465 [Mode::BeforeAfter, Mode::PinnedKafka]
466 .into_iter()
467 .find(|mode| mode.name() == raw)
468 .ok_or_else(|| artifact_error(path, format!("mode={raw} is not a mode")))
469}
470
471pub fn assemble(artifact_dir: &Path) -> Result<PerformanceRecord, BudgetError> {
484 let contract_bytes = fs::read(artifact_dir.join("contract.json"))?;
485 let contract = Contract::from_json(&contract_bytes)?;
486 let mut provenance = parse_env(
487 "provenance.env",
488 &fs::read_to_string(artifact_dir.join("provenance.env"))?,
489 )?;
490 let path = "provenance.env";
491 let recorded_digest = take(path, &mut provenance, "contract_sha256")?;
492 if recorded_digest != contract_digest(&contract_bytes) {
493 return Err(artifact_error(
494 "contract.json",
495 "its bytes do not match the contract_sha256 recorded before the run",
496 ));
497 }
498 let mode = parse_mode(path, &take(path, &mut provenance, "mode")?)?;
499 let mut subject = |side: Side| -> Result<Subject, BudgetError> {
500 let prefix = side.name();
501 Ok(Subject {
502 label: take(path, &mut provenance, &format!("{prefix}_label"))?,
503 revision: take(path, &mut provenance, &format!("{prefix}_revision"))?,
504 image: take(path, &mut provenance, &format!("{prefix}_image"))?,
505 image_digest: take(path, &mut provenance, &format!("{prefix}_image_digest"))?,
506 })
507 };
508 let reference = subject(Side::Reference)?;
509 let candidate = subject(Side::Candidate)?;
510 let client_image = take(path, &mut provenance, "client_image")?;
511 let client_image_digest = take(path, &mut provenance, "client_image_digest")?;
512 let started_at = take(path, &mut provenance, "started_at")?;
513 let host = parse_host(&fs::read_to_string(artifact_dir.join("host.env"))?)?;
514
515 let mut trials = Vec::new();
516 for round in 1..=contract.rounds {
517 for side in Side::BOTH {
518 for cell in &contract.cells {
519 let relative = format!("trials/round-{round}/{}/{}", side.name(), cell.id);
520 let directory: PathBuf = artifact_dir.join(&relative);
521 if !directory.is_dir() {
522 continue;
523 }
524 let order_path = format!("{relative}/order");
525 let order_text = fs::read_to_string(directory.join("order"))?;
526 let order = order_text
527 .trim()
528 .parse()
529 .map_err(|_| artifact_error(&order_path, "not a trial position"))?;
530 trials.push(Trial {
531 cell: cell.id.clone(),
532 round,
533 side,
534 order,
535 outcome: trial_outcome(&directory, &relative, host.clock_ticks_per_second)?,
536 });
537 }
538 }
539 }
540 trials.sort_by_key(|trial| trial.order);
541 Ok(PerformanceRecord {
542 format_version: FORMAT_VERSION,
543 contract_name: contract.name,
544 contract_sha256: recorded_digest,
545 mode,
546 reference,
547 candidate,
548 client_image,
549 client_image_digest,
550 host,
551 started_at,
552 settings: provenance,
553 trials,
554 })
555}