crates/nornir-bench/src/history.rs
//! Append-only JSONL history (`bench_history.json`).
//!
//! One [`BenchRun`] per line. Existing lines are never edited or
//! reordered. Writes are flushed + fsynced before returning.
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use anyhow::{Context, Result};
use super::BenchRun;
/// Append one run to `path`. Creates the file if missing.
/// Refuses runs with an empty `machine` to preserve same-machine
/// comparison semantics.
pub fn append(path: &Path, run: &BenchRun) -> Result<()> {
if run.machine.trim().is_empty() {
return Err(anyhow::anyhow!(
"BenchRun.machine is required for appended runs"
));
}
let line = serde_json::to_string(run).context("serialise bench run")?;
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("open {}", path.display()))?;
writeln!(f, "{line}").context("write bench history line")?;
f.flush()?;
f.sync_all()?;
Ok(())
}
/// Read all runs from `path`. Returns empty Vec if file does not exist.
pub fn read_all(path: &Path) -> Result<Vec<BenchRun>> {
if !path.exists() {
return Ok(Vec::new());
}
let f = File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut out = Vec::new();
for (idx, line) in BufReader::new(f).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let run: BenchRun = serde_json::from_str(&line)
.with_context(|| format!("parse line {} of {}", idx + 1, path.display()))?;
out.push(run);
}
Ok(out)
}
/// Most recent run for a given machine. Used by the no-regression gate.
pub fn last_for_machine<'a>(history: &'a [BenchRun], machine: &str) -> Option<&'a BenchRun> {
history.iter().rev().find(|r| r.machine == machine)
}
/// One-shot migration: read a legacy `bench_history.json` (which is
/// already JSONL by accident — one object per line — but with some
/// envelope fields missing) and write `<path>.jsonl` next to it with
/// `version` / `machine` / `cores` / `timestamp` backfilled from
/// `defaults` (timestamp inferred from `date` as `${date}T00:00:00Z`
/// if not already set).
///
/// `tests` defaults to empty; legacy runs predate the test-outcome
/// field. Existing legacy file is left untouched: the SPEC mandates
/// that existing history lines are never edited. The new `.jsonl`
/// becomes the active append target.
pub fn migrate_legacy(legacy: &Path, defaults: &BenchRun) -> Result<std::path::PathBuf> {
let runs = read_all(legacy)?;
let target = legacy.with_extension("jsonl");
let mut out = String::new();
for mut r in runs {
if r.version.is_empty() { r.version = defaults.version.clone(); }
if r.machine.is_empty() { r.machine = defaults.machine.clone(); }
if r.cores == 0 { r.cores = defaults.cores; }
if r.timestamp.is_none() {
r.timestamp = Some(format!("{}T00:00:00Z", r.date));
}
out.push_str(&serde_json::to_string(&r)?);
out.push('\n');
}
std::fs::write(&target, out).with_context(|| format!("write {}", target.display()))?;
Ok(target)
}
#[cfg(test)]
mod tests {
use super::super::{BenchResult, BenchRun, TestOutcome};
#[test]
fn legacy_line_parses_without_timestamp_or_tests() {
let legacy = r#"{"date":"2026-05-27","version":"0.6.4","machine":"ryzen-9-7900","cores":32,"results":[{"name":"rust_crate_st","ops_sec":420072}]}"#;
let run: BenchRun = serde_json::from_str(legacy).unwrap();
assert_eq!(run.date, "2026-05-27");
assert!(run.timestamp.is_none());
assert!(run.tests.is_empty());
assert_eq!(run.results.len(), 1);
assert!(run.all_tests_passed(), "empty tests => passes");
}
#[test]
fn new_line_roundtrips_with_tests() {
let run = BenchRun {
id: crate::Uuid::nil(),
machine_facts: None,
date: "2026-05-30".into(),
timestamp: Some("2026-05-30T21:00:00Z".into()),
version: "0.6.5".into(),
machine: "ryzen-9-7900".into(),
cores: 32,
results: vec![BenchResult {
name: "x".into(),
metrics: serde_json::Map::new(),
..Default::default()
}],
tests: vec![
TestOutcome { name: "a".into(), passed: true, duration_ms: Some(1.2), message: None },
TestOutcome { name: "b".into(), passed: false, duration_ms: None, message: Some("oops".into()) },
],
};
let s = serde_json::to_string(&run).unwrap();
let back: BenchRun = serde_json::from_str(&s).unwrap();
assert_eq!(back.failed_tests(), vec!["b"]);
assert!(!back.all_tests_passed());
assert_eq!(back.timestamp.as_deref(), Some("2026-05-30T21:00:00Z"));
}
/// Symmetric-write guarantee (bench-mashup fix): `append` must leave a
/// real on-disk, git-committable JSONL receipt that `read_all` reads back
/// intact — the fallback tier the docs mashup reads when the warehouse is
/// unreachable. The CLI `bench run` now relies on this (it appends here in
/// addition to the iceberg warehouse row), exactly like the fat server's
/// `Ops.RunBench`.
#[test]
fn append_writes_committable_jsonl_receipt() {
let dir = std::env::temp_dir().join(format!(
"nornir-bench-hist-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bench_history.jsonl");
let mk = |name: &str| BenchRun {
id: crate::Uuid::nil(),
machine_facts: None,
date: "2026-07-10".into(),
timestamp: Some("2026-07-10T12:00:00Z".into()),
version: "0.7.0".into(),
machine: "ryzen-9-7900".into(),
cores: 32,
results: vec![BenchResult {
name: name.into(),
metrics: serde_json::Map::new(),
..Default::default()
}],
tests: vec![],
};
// Two sequential appends (as two CLI `bench run` invocations would).
super::append(&path, &mk("zoomies-lgz")).unwrap();
super::append(&path, &mk("zoomies-lgz_st")).unwrap();
assert!(path.exists(), "receipt file must exist on disk (committable)");
let back = super::read_all(&path).unwrap();
assert_eq!(back.len(), 2, "both appended runs are readable back");
assert_eq!(back[0].results[0].name, "zoomies-lgz");
assert_eq!(back[1].results[0].name, "zoomies-lgz_st");
// An empty-machine run is refused so same-machine comparison holds.
let mut bad = mk("x");
bad.machine = "".into();
assert!(super::append(&path, &bad).is_err());
std::fs::remove_dir_all(&dir).ok();
}
}