8000 fix(db): checkpoint inside a WriteTransaction returns INT-001 instead of deadlocking (#734) — backport by adityamukho · Pull Request #740 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
8000
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docs/ERROR_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -832,6 +835,10 @@ impl Minigraf {
) -> Result<crate::query::datalog::prepared::PreparedQuery> {
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 {
Expand Down
3 changes: 3 additions & 0 deletions src/query/datalog/prepared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ impl PreparedQuery {
}

fn execute_inner(&self, bindings: &[(&str, BindValue)]) -> Result<QueryResult> {
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();

Expand Down
74 changes: 74 additions & 0 deletions tests/write_tx_guard_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// 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.
/// 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> {
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();
}
Loading
0