Files
wanderbooks/crates/ledger-store/tests/concurrency.rs
T
swaitsandClaude Opus 5.5 a833229d87 tests: every test dir is removed when its test ends, pass or panic
Many test helpers removed their temp dir only before the test, or only
on pass, so runs left them behind: 117,000 filled /tmp (RAM here) and
stopped a mutation run. Each now holds a guard that removes the dir on
drop, unwind included; servers started on a test dir hold its guard
until they stop. A full cargo test --workspace leaves 0 dirs (859
before). just sweep-test-tmp stays for runs killed mid-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 20:47:44 -06:00

151 lines
5.4 KiB
Rust

//! Concurrency (rank 2): the viewer reads while the agent writes.
//!
//! The viewer ([`Snapshot`]) follows the live journal of a [`Store`] that
//! another thread (in production, another process) is writing as fast as
//! it can. Its own spec (snapshot.rs): a line still being appended is left
//! for the next refresh, never treated as corruption; it never writes.
//!
//! Oracle, at every refresh: the snapshot's books are whole (the trial
//! balance balances, every posted entry is valid), they never go backward,
//! and the writer's database is never seen to differ from the journal
//! (behind is fine: the writer applies right after journaling). At the end
//! the snapshot equals the writer, every part of the ledger, attachments
//! included.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{DiskCheck, Snapshot, Store};
use rust_decimal::Decimal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Barrier};
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!("wanderbooks-conc-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
const ENTRIES: u32 = 400;
#[test]
fn a_viewer_reading_while_the_books_are_written_sees_only_whole_books() {
let dir = test_dir("viewer");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let sales = store
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let mut snapshot = Snapshot::load(&dir).unwrap();
let done = Arc::new(AtomicBool::new(false));
let start = Arc::new(Barrier::new(2));
let writer = {
let (done, start) = (done.clone(), start.clone());
std::thread::spawn(move || {
start.wait();
for n in 0..ENTRIES {
let day = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
+ chrono::Days::new(u64::from(n % 365));
let e = store.create_entry(day, &format!("sale {n}")).unwrap();
let m = Money::new(Decimal::new(i64::from(n) * 100 + 1, 2), Currency::USD);
store.add_line(e, cash, Side::Debit, m).unwrap();
store.add_line(e, sales, Side::Credit, m).unwrap();
if n % 3 == 0 {
// Leave every third a draft for a while: drafts are
// journaled too, and must read back whole.
continue;
}
if n % 10 == 1 {
store
.attach_receipt(
Some(e),
&format!("r{n}.pdf"),
"application/pdf",
format!("receipt {n}").as_bytes(),
None,
)
.unwrap();
}
store.post_entry(e).unwrap();
}
done.store(true, Ordering::SeqCst);
store
})
};
start.wait();
let (mut last_seq, mut last_posted, mut refreshes) = (0u64, 0usize, 0u64);
loop {
// Done, or the writer died (its panic surfaces at the join below).
let finished = done.load(Ordering::SeqCst) || writer.is_finished();
snapshot.refresh().unwrap();
refreshes += 1;
let l = snapshot.ledger();
let tb = ledger_core::reports::trial_balance(l);
assert_eq!(tb.total_debits, tb.total_credits, "refresh {refreshes}");
for e in l.posted_entries() {
assert!(
e.validate(l.chart()).is_ok(),
"entry {} at refresh {refreshes}",
e.id()
);
}
let posted = l.posted_entries().count();
assert!(
snapshot.journal_seq() >= last_seq,
"the journal went backward"
);
assert!(posted >= last_posted, "posted entries went backward");
(last_seq, last_posted) = (snapshot.journal_seq(), posted);
match snapshot.check_disk() {
DiskCheck::Matches | DiskCheck::Behind { .. } | DiskCheck::Ahead { .. } => {}
other => panic!("refresh {refreshes}: the database {other:?}"),
}
if finished {
break;
}
}
let store = writer.join().unwrap();
snapshot.refresh().unwrap();
let parts = store.ledger().differs_from(snapshot.ledger());
assert!(
parts.is_empty(),
"the viewer differs from the writer in {parts:?}"
);
assert_eq!(
snapshot.attachments(None).unwrap().len(),
store.list_attachments(None).unwrap().len()
);
assert_eq!(snapshot.check_disk(), DiskCheck::Matches);
assert!(refreshes > 1, "the reader never overlapped the writer");
if std::env::var("CONC_STATS").is_ok() {
eprintln!("REFRESHES {refreshes}");
}
let _ = std::fs::remove_dir_all(&dir);
}