diff --git a/Cargo.lock b/Cargo.lock index 66d4905..0db44a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3564,6 +3564,7 @@ name = "ledger-store" version = "0.1.0" dependencies = [ "chrono", + "criterion", "hex", "ledger-core", "proptest", diff --git a/crates/ledger-store/Cargo.toml b/crates/ledger-store/Cargo.toml index 604e8fb..b3ce52a 100644 --- a/crates/ledger-store/Cargo.toml +++ b/crates/ledger-store/Cargo.toml @@ -21,6 +21,11 @@ hex = "0.4" tempfile = "3" proptest = "1" rust_decimal = "1" +criterion = "0.8.2" + +[[bench]] +name = "store" +harness = false [lints] workspace = true diff --git a/crates/ledger-store/benches/store.rs b/crates/ledger-store/benches/store.rs new file mode 100644 index 0000000..d2d60df --- /dev/null +++ b/crates/ledger-store/benches/store.rs @@ -0,0 +1,98 @@ +//! Benchmarks for the store's hot paths (rank 6: a baseline, never +//! correctness; no budget, by decision, 2026-10-13). +//! +//! `cargo bench -p ledger-store --bench store`. Books of 10k two-line +//! entries, on a real disk (under `target/`), since every journal append +//! is synced and a RAM disk would hide that: opening them (verify the hash +//! chain, replay, catch the database up), what the viewer does to load +//! them, a full verify, and posting one more entry (four synced appends). + +use chrono::NaiveDate; +use criterion::{Criterion, criterion_group, criterion_main}; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::{Snapshot, Store}; +use rust_decimal::Decimal; +use std::path::{Path, PathBuf}; + +const ENTRIES: u32 = 10_000; + +fn post(store: &mut Store, n: u32, a: u64, b: u64) { + let day = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n % 365)); + let e = store.create_entry(day, "bench").unwrap(); + let m = Money::new(Decimal::new(i64::from(n % 50_000) + 1, 2), Currency::USD); + store.add_line(e, a, Side::Debit, m).unwrap(); + store.add_line(e, b, Side::Credit, m).unwrap(); + store.post_entry(e).unwrap(); +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap() { + let e = e.unwrap(); + let target = to.join(e.file_name()); + if e.file_type().unwrap().is_dir() { + copy_dir(&e.path(), &target); + } else { + std::fs::copy(e.path(), target).unwrap(); + } + } +} + +/// 10k entries over 40 accounts, built in a temp dir (fast) and copied to +/// disk under target/ (honest syncs). Built once per run. +fn books() -> PathBuf { + let disk = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/bench-books"); + let _ = std::fs::remove_dir_all(&disk); + let tmp = tempfile::tempdir().unwrap(); + { + let mut store = Store::create(tmp.path(), Currency::USD).unwrap(); + let types = [ + AccountType::Asset, + AccountType::Liability, + AccountType::Equity, + AccountType::Revenue, + AccountType::Expense, + ]; + let ids: Vec = (0..40) + .map(|i| { + store + .add_account(&format!("{}", 1000 + i), "a", types[i % 5], None, false) + .unwrap() + }) + .collect(); + for n in 0..ENTRIES { + let a = ids[(n as usize * 7) % 40]; + let b = ids[(n as usize * 13 + 1) % 40]; + if a != b { + post(&mut store, n, a, b); + } + } + } + let _ = std::fs::remove_file(tmp.path().join(ledger_store::LOCK_FILE)); + copy_dir(tmp.path(), &disk); + disk +} + +fn bench(c: &mut Criterion) { + let dir = books(); + let mut g = c.benchmark_group("store_10k"); + g.sample_size(10); + g.bench_function("open", |b| b.iter(|| Store::open(&dir).unwrap())); + g.bench_function("viewer_load", |b| b.iter(|| Snapshot::load(&dir).unwrap())); + g.bench_function("verify", |b| { + let store = Store::open(&dir).unwrap(); + b.iter(|| store.verify().unwrap()) + }); + g.bench_function("post_one_entry", |b| { + let mut store = Store::open(&dir).unwrap(); + let mut n = ENTRIES; + b.iter(|| { + n += 1; + post(&mut store, n, 1, 2); + }) + }); + g.finish(); +} + +criterion_group!(benches, bench); +criterion_main!(benches); diff --git a/docs/testing.md b/docs/testing.md index e0a2c13..9a9b03f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ the store's journal safety; the other crates come next (gap list). | Fuzzing | crash, hang, broken books (4) | `fuzz/fuzz_targets/`, corpora in `fuzz/corpus/` | `just fuzz ` | 10 min each | weekly | | Miri | undefined behavior in our use of dependencies (4) | the core's unit, bridge, invariant tests | `just miri` | ~2 min | on demand | | Golden | `tests/golden/fixture_company.json` (5) | `invariants.rs` | `just check` | <1s | every change | -| Benchmarks | a baseline (6) | `benches/books.rs` | `just bench` | ~1 min | none (no budget, by decision) | +| Benchmarks | a baseline (6) | `ledger-core/benches/books.rs`, `ledger-store/benches/store.rs` | `just bench` | ~2 min | none (no budget, by decision) | | Mutation | can the tests fail? | `ledger-core` (2026-10-13: 1,011 caught + 13 timeouts of 1,024 viable, 0 missed) | `just mutants` | ~3 h | weekly | CI: `.github/workflows/check.yml` runs `just check` on every push; @@ -57,6 +57,16 @@ laptop: | balance sheet | 47 µs | 1.04 ms | | one month's P&L | 25 µs | 248 µs | +The store, 10k two-line entries on the laptop's disk (every journal +append is synced; `benches/store.rs` in ledger-store): + +| | 10k entries | +|---|---| +| open the books (verify the chain, replay, catch the database up) | 249 ms | +| the viewer loading them | 616 ms | +| a full verify (replay, projection, every table) | 809 ms | +| post one entry (create, two lines, post: four synced appends) | 62 ms | + ## Formerly assumed: checked against the standards (2026-10-13) Stephen isn't an accountant and won't adjudicate these; each was checked diff --git a/justfile b/justfile index a246de8..979488c 100644 --- a/justfile +++ b/justfile @@ -69,6 +69,7 @@ mutants: # Benchmarks of the core's hot paths (no budget gates them yet) bench: cargo bench -p ledger-core --bench books + cargo bench -p ledger-store --bench store # Build the release binaries (CLI, MCP server, GUI) build: