From 6d4012c451ccc44e1725f86acfac653cae195af4 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sat, 24 Oct 2026 13:11:57 +0530 Subject: [PATCH 1/2] =?UTF-8?q?fix(db):=20checkpoint=20inside=20a=20WriteT?= =?UTF-8?q?ransaction=20returns=20INT-001=20instead=20of=20deadlocking=20(?= =?UTF-8?q?#734)=20=E2=80=94=20backport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkpoint locked the write lock its own thread's WriteTransaction held. prepare and PreparedQuery::execute get the same guard for consistency. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NBT2Yx9JUQKYvv2CvC9py --- CHANGELOG.md | 4 ++ CLAUDE.md | 2 +- docs/ERROR_REFERENCE.md | 6 ++- docs/TEST_COVERAGE.md | 1 + src/db.rs | 9 ++++- src/query/datalog/prepared.rs | 3 ++ tests/write_tx_guard_test.rs | 73 +++++++++++++++++++++++++++++++++++ 7 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tests/write_tx_guard_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc38f337..c5c55a909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`checkpoint` inside a `WriteTransaction` returns `INT-001` instead of deadlocking** (#734), backported from v3.0.0. Called on a thread that holds a `WriteTransaction`, `checkpoint` waited on the write lock that the transaction already held, so the thread hung. It now returns `INT-001`, as `execute` and `begin_write` already did, and the transaction stays usable. `prepare` and `PreparedQuery::execute` make the same check: they could not deadlock, but they ran inside the transaction while `execute` refused. Present since `WriteTransaction` was added. + ## v2.0.10 — 2026-10-23 Patch release on the v2.x line under the [support policy](PHILOSOPHY.md#support-policy): two data-integrity fixes, for WAL entries of more than 1,000,000 facts that replay dropped along with every later transaction (#686) and for float constants that matched any float within `f64::EPSILON` (#706), plus prepared queries that ignored the database's rule limits (#679). File format is unchanged (v7). No API changes. Upgrading is recommended for every v2.x user who commits transactions of more than 1,000,000 facts or matches float constants in patterns. diff --git a/CLAUDE.md b/CLAUDE.md index 4cc853a0d..723e6643f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,7 @@ Auto-migrates v1/v2/v3/v4/v5/v6 → v7 on open/checkpoint. ## Test Coverage -**1326 tests** (1317 passing, 9 ignored; unit + integration + doc). +**1330 tests** (1321 passing, 9 ignored; unit + integration + doc). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. **Testing conventions** — see the Testing Conventions section below before writing any tests. diff --git a/docs/ERROR_REFERENCE.md b/docs/ERROR_REFERENCE.md index 0fda3401b..70fe483b2 100644 --- a/docs/ERROR_REFERENCE.md +++ b/docs/ERROR_REFERENCE.md @@ -2338,8 +2338,10 @@ specific code, so this should now be rare. **Error text**: `a WriteTransaction is already in progress on this thread; use tx.execute() instead` -**Cause**: `Minigraf::execute()` or `Minigraf::begin_write()` was called on -the same thread that already holds an active `WriteTransaction`. Acquiring +**Cause**: `Minigraf::execute()`, `begin_write()`, `checkpoint()`, +`prepare()` or `PreparedQuery::execute()` was called on the same thread that +already holds an active `WriteTransaction` (`checkpoint()` deadlocked instead +before #734). Acquiring the write lock again on that thread would deadlock, so this is detected and rejected instead. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index ebd0434c5..d56c9a1c2 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -93,6 +93,7 @@ These tests compare the database against an independent answer rather than a sin ### Concurrency - **Snapshot atomicity** (`tests/snapshot_atomicity_test.rs`, #554, #555, #558): queries see each concurrent transaction whole and read one state for all their patterns, also during a checkpoint. +- **Calls inside a write transaction** (`tests/write_tx_guard_test.rs`, 4 tests; #734): `checkpoint`, `prepare` and `PreparedQuery::execute` on a thread holding a `WriteTransaction` return `INT-001` (under a 30 s timeout, so a deadlock fails instead of hanging) and the transaction then commits; outside a transaction they run. ### Queries diff --git a/src/db.rs b/src/db.rs index e9e84a423..03254fd78 100644 --- a/src/db.rs +++ b/src/db.rs @@ -54,7 +54,7 @@ fn set_write_tx_active(val: bool) { WRITE_TX_ACTIVE.with(|f| f.set(val)); } -fn is_write_tx_active() -> bool { +pub(crate) fn is_write_tx_active() -> bool { WRITE_TX_ACTIVE.with(|f| f.get()) } @@ -727,6 +727,9 @@ impl Minigraf { } fn checkpoint_inner(&self) -> Result<()> { + if is_write_tx_active() { + bail_coded!(ErrorCode::Int001); + } let mut ctx = self .inner .write_lock @@ -832,6 +835,10 @@ impl Minigraf { ) -> Result { use crate::query::datalog::prepared::prepare_query; + if is_write_tx_active() { + bail_coded!(ErrorCode::Int001); + } + let cmd = parse_datalog_command(query_str)?; let query = match cmd { diff --git a/src/query/datalog/prepared.rs b/src/query/datalog/prepared.rs index 178b3e361..05d84a17f 100644 --- a/src/query/datalog/prepared.rs +++ b/src/query/datalog/prepared.rs @@ -106,6 +106,9 @@ impl PreparedQuery { } fn execute_inner(&self, bindings: &[(&str, BindValue)]) -> Result { + if crate::db::is_write_tx_active() { + bail_coded!(ErrorCode::Int001); + } let binding_map: HashMap<&str, &BindValue> = bindings.iter().map(|(name, val)| (*name, val)).collect(); diff --git a/tests/write_tx_guard_test.rs b/tests/write_tx_guard_test.rs new file mode 100644 index 000000000..6d68c1374 --- /dev/null +++ b/tests/write_tx_guard_test.rs @@ -0,0 +1,73 @@ +// File-backed (Minigraf::open, tempfile): native only, like the other file tests. +#![cfg(not(target_arch = "wasm32"))] +//! A database call on a thread that holds a `WriteTransaction` returns +//! `INT-001` instead of waiting on the transaction's own write lock (#734). + +use minigraf::{Minigraf, PreparedQuery}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +/// Runs `call` on a fresh thread holding a `WriteTransaction` on a file +/// database, and returns the error code `call` gave, or `None` if it +/// succeeded. A call that deadlocks fails the test after 30 s instead of +/// hanging it. The transaction must still commit after the call. +fn code_inside_tx( + call: fn(&Minigraf, &PreparedQuery) -> Option<&'static str>, +) -> Option<&'static str> { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let dir = tempfile::tempdir().unwrap(); + let db = Minigraf::open(dir.path().join("g.graph")).unwrap(); + db.execute(r#"(transact [[:alice :person/name "Alice"]])"#) + .unwrap(); + let prepared = db + .prepare("(query [:find ?n :where [?e :person/name ?n]])") + .unwrap(); + let mut wtx = db.begin_write().unwrap(); + wtx.execute(r#"(transact [[:bob :person/name "Bob"]])"#) + .unwrap(); + let code = call(&db, &prepared); + wtx.commit() + .expect("the transaction commits after the refusal"); + tx.send(code).unwrap(); + }); + rx.recv_timeout(Duration::from_secs(30)) + .expect("the call returned instead of deadlocking") +} + +#[test] +fn checkpoint_inside_write_tx_is_int001() { + let code = code_inside_tx(|db, _| db.checkpoint().err().map(|e| e.code())); + assert_eq!(code, Some("INT-001")); +} + +#[test] +fn prepare_inside_write_tx_is_int001() { + let code = code_inside_tx(|db, _| { + db.prepare("(query [:find ?n :where [?e :person/name ?n]])") + .err() + .map(|e| e.code()) + }); + assert_eq!(code, Some("INT-001")); +} + +#[test] +fn prepared_execute_inside_write_tx_is_int001() { + let code = code_inside_tx(|_, p| p.execute(&[]).err().map(|e| e.code())); + assert_eq!(code, Some("INT-001")); +} + +/// Outside a transaction the same calls still run. +#[test] +fn guarded_calls_run_outside_a_write_tx() { + let dir = tempfile::tempdir().unwrap(); + let db = Minigraf::open(dir.path().join("g.graph")).unwrap(); + db.execute(r#"(transact [[:alice :person/name "Alice"]])"#) + .unwrap(); + db.checkpoint().unwrap(); + let prepared = db + .prepare("(query [:find ?n :where [?e :person/name ?n]])") + .unwrap(); + prepared.execute(&[]).unwrap(); +} From a0b65918b3e23bbeedcb65cd2ebfb132ab6d9218 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sat, 24 Oct 2026 13:13:56 +0530 Subject: [PATCH 2/2] test(#734): note the blocked thread on a deadlock Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NBT2Yx9JUQKYvv2CvC9py --- tests/write_tx_guard_test.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/write_tx_guard_test.rs b/tests/write_tx_guard_test.rs index 6d68c1374..1a055986d 100644 --- a/tests/write_tx_guard_test.rs +++ b/tests/write_tx_guard_test.rs @@ -12,6 +12,7 @@ use std::time::Duration; /// database, and returns the error code `call` gave, or `None` if it /// succeeded. A call that deadlocks fails the test after 30 s instead of /// hanging it. The transaction must still commit after the call. +/// A deadlocked thread is left blocked until the test process exits. fn code_inside_tx( call: fn(&Minigraf, &PreparedQuery) -> Option<&'static str>, ) -> Option<&'static str> {