From 8c8ba19471f4b7c5652358e0976f61702d999d86 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 16:05:59 -0600 Subject: [PATCH] fix(store): one writer at a time; a second is refused, not allowed to corrupt the books Max-testing pass on ledger-store, found during the survey: nothing stopped two processes from opening the same books for writing. Two agent sessions (two MCP servers) on one books dir would each extend the hash chain from the same last line; one write from each broke the journal ("seq gap: expected 3, found 2") and the books would not open. The store now holds an exclusive lock on books-data/wanderbooks.lock (std's File::try_lock, no new dependency) from before it reads the journal until it is dropped; the OS releases it if the process dies. A second writer gets StoreError::Locked saying another process has the books open. The viewer reads through Snapshot and never takes the lock. The lock file is git-ignored in the books scaffold. Regression: a_second_writer_is_refused_while_the_first_has_the_books (refused while open, allowed after close, books whole). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-cli/src/main.rs | 2 ++ crates/ledger-store/src/error.rs | 6 +++++ crates/ledger-store/src/lib.rs | 2 +- crates/ledger-store/src/store.rs | 43 ++++++++++++++++++++++++++++++ crates/ledger-store/tests/store.rs | 26 ++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index d9a62c3..574e52b 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -252,6 +252,8 @@ open_at_start = "ask" const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl. books-data/wanderbooks.db books-data/wanderbooks.db-* +# Held while an agent's MCP server has the books open: one writer at a time. +books-data/wanderbooks.lock # Generated exports can always be regenerated from the journal. exports/ # The background viewer's log (`just gui`). diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index dab4c83..05cf044 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -65,6 +65,12 @@ pub enum StoreError { #[error("store already exists at {0}")] AlreadyExists(String), + /// Another process has these books open for writing. One writer at a + /// time: two would each extend the hash chain from the same place and + /// break it (found by the max-testing pass, 2026-10-13). + #[error("{0}")] + Locked(String), + /// `open` called where no store exists. #[error("store not found at {0}")] NotFound(String), diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 3e2a2c9..37a97df 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -32,5 +32,5 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter}; pub use snapshot::{DiskCheck, Snapshot}; pub use store::{ Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE, - PayrollLineInput, Store, VerifyReport, + LOCK_FILE, PayrollLineInput, Store, VerifyReport, }; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 8af53c1..205331b 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -29,6 +29,40 @@ use std::path::{Path, PathBuf}; pub const JOURNAL_FILE: &str = "journal.jsonl"; /// File names inside the data directory. pub const DB_FILE: &str = "wanderbooks.db"; + +/// The lock file a writer holds while the books are open. Readers (the +/// viewer's [`crate::Snapshot`]) never take it. +pub const LOCK_FILE: &str = "wanderbooks.lock"; + +/// Take the books' writer lock, or say who has it. +/// +/// ```text +/// GOAL: one writer at a time. +/// SOURCE: R-11 (one append-only log); docs/receipt-store.md (single write +/// path). +/// SCENARIOS: +/// 1. IF another process (or another Store in this one) has the books +/// open for writing: +/// - SHOULD refuse with Locked, saying so +/// - NEVER let two writers extend the journal +/// BECAUSE each would chain from the same last line and break it. +/// ``` +fn lock_books(data_dir: &Path) -> Result { + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(data_dir.join(LOCK_FILE))?; + match file.try_lock() { + Ok(()) => Ok(file), + Err(std::fs::TryLockError::WouldBlock) => Err(StoreError::Locked(format!( + "another wanderbooks process has the books in {} open for writing (an agent session, \ + or its MCP server); close it first. The viewer is fine: it only reads", + data_dir.display() + ))), + Err(std::fs::TryLockError::Error(e)) => Err(StoreError::Io(e)), + } +} pub(crate) const BLOBS_DIR: &str = "blobs"; /// One row of an entry listing. @@ -133,6 +167,10 @@ pub type PayrollLineInput = PayrollLine; /// `Store` is `Send` but not `Sync` (it owns a `rusqlite::Connection`). /// There is exactly one writer per data directory. pub struct Store { + /// The exclusive lock on [`LOCK_FILE`], held while the store is open: + /// one writer at a time. Released when dropped, or when the process + /// exits, even after a crash. + _lock: std::fs::File, data_dir: PathBuf, db_path: PathBuf, journal: JournalWriter, @@ -165,12 +203,14 @@ impl Store { return Err(StoreError::AlreadyExists(data_dir.display().to_string())); } std::fs::create_dir_all(data_dir)?; + let lock = lock_books(data_dir)?; let db_path = data_dir.join(DB_FILE); let conn = Self::open_conn(&db_path)?; Self::migrate(&conn)?; Self::write_meta(&conn, "base_currency", base_currency.code())?; Self::write_meta(&conn, "last_applied_seq", "0")?; let mut store = Self { + _lock: lock, data_dir: data_dir.to_path_buf(), db_path, journal: JournalWriter::open(&journal_path)?, @@ -195,6 +235,8 @@ impl Store { if !journal_path.exists() { return Err(StoreError::NotFound(data_dir.display().to_string())); } + // The writer lock first: nothing is read until no one else writes. + let lock = lock_books(data_dir)?; // Single verified scan; the writer reuses it. let lines = read_and_verify_journal(&journal_path)?; let journal = JournalWriter::from_lines(&journal_path, &lines)?; @@ -253,6 +295,7 @@ impl Store { cur }; let mut store = Self { + _lock: lock, data_dir: data_dir.to_path_buf(), db_path, journal, diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 953a940..3eae5c0 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1088,3 +1088,29 @@ fn a_journal_line_with_a_float_is_refused() { canon ); } + +/// Regression (max-testing pass on the store, 2026-10-13): nothing stopped +/// two processes (say, two agent sessions, each with its own MCP server) +/// from writing the same books. One write from each broke the hash chain +/// ("seq gap") and the books would not open. Oracle (rank 1, R-11: one +/// append-only log, one writer): a second writer is refused while the +/// first is open, and allowed once it closes; the books always reopen. +#[test] +fn a_second_writer_is_refused_while_the_first_has_the_books() { + let dir = test_dir("two-writers"); + drop(Store::create(&dir, Currency::USD).unwrap()); + let mut first = Store::open(&dir).unwrap(); + match Store::open(&dir) { + Err(StoreError::Locked(why)) => assert!(why.contains("another"), "{why}"), + Err(e) => panic!("expected Locked, got {e}"), + Ok(_) => panic!("a second writer was allowed"), + } + first + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + drop(first); + // Released on close: the next writer gets in, and the books are whole. + let reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + assert_eq!(reopened.ledger().chart().len(), 1); +}