ACID transaction guarantees with commit, rollback, and conflict detection
jasonisnthappy provides full ACID transactions with snapshot isolation. Every read and write operation happens within a transaction, ensuring data consistency even under concurrent access.
use jasonisnthappy::core::database::Database;let db = Database::open("mydb.db")?;let mut tx = db.begin()?;// Transaction is now activeassert!(tx.is_active());
When a transaction begins (src/core/transaction.rs:56):
Allocate transaction ID - Globally unique, monotonically increasing (xmin)
Capture snapshot ID - Timestamp of the latest committed transaction
Take collection roots snapshot - Record current B-tree root for each collection
Register with MVCC manager - Track as active transaction
The snapshot ID determines which document versions this transaction can see. See MVCC for details.
Either all changes commit or none do. If a transaction fails halfway through commit (e.g., disk full), the WAL ensures the database remains consistent.Example: Atomic transfer
Transactions move the database from one valid state to another. Schema validation (if configured) runs before commit:
db.set_schema("users", schema)?;let mut tx = db.begin()?;let mut users = tx.collection("users")?;// This will fail at commit if schema validation failsusers.insert(json!({"name": 123}))?; // name must be stringtx.commit()?; // Error: schema validation failed
Committed changes survive crashes through the write-ahead log:
Changes are written to WAL before commit succeeds
WAL is synced to disk with fsync()
On database open, WAL is replayed to recover uncommitted changes
Durability requires fsync(), which is slow (~8ms per commit on typical SSDs). For higher throughput, use batch operations or increase max_bulk_operations.
The commit fails with Error::TxConflict because transaction 2 modified alice after transaction 1’s snapshot.How it works (src/core/transaction.rs:357):
fn detect_write_conflicts(&self, collection_name: &str, current_root: PageNum) -> Result<()> { // For each document we modified: for (doc_id, _) in collection_writes.iter() { // Get the xmin we saw when we first read it let original_xmin = doc_original_xmin.get(doc_id)?; // Read the CURRENT committed version from B-tree let committed_vdoc = read_versioned_document(pager, page_num)?; // Conflict if xmin changed AND new version is after our snapshot if committed_vdoc.xmin != original_xmin && committed_vdoc.xmin > self.snapshot_id { return Err(Error::TxConflict); } }}
New inserts never conflict - if a document didn’t exist in your snapshot, no conflict check is needed. This optimization is at src/core/transaction.rs:401.
Use run_transaction() to automatically retry on conflicts:
let result = db.run_transaction(|tx| { let mut users = tx.collection("users")?; let mut alice = users.find_one("alice")?; alice["score"] = json!(alice["score"].as_i64().unwrap() + 10); users.update("alice", alice)?; Ok(alice["score"].as_i64().unwrap())})?;
Transactions can also create, drop, and rename collections:
let mut tx = db.begin()?;// Create collectiontx.create_collection("posts")?;// Rename collectiontx.rename_collection("posts", "articles")?;// Drop collection (deletes all documents)tx.drop_collection("articles")?;tx.commit()?;
Open a database in read-only mode for safe concurrent access without locks:
let opts = DatabaseOptions { read_only: true, ..Default::default()};let db = Database::open_with_options("mydb.db", opts)?;let tx = db.begin()?;// Can read, but commit() will fail
Read-only mode uses shared file locks, allowing multiple processes to read the same database concurrently.