Snapshot isolation through version management and garbage collection
MVCC (Multi-Version Concurrency Control) is the technique jasonisnthappy uses to provide snapshot isolation: multiple transactions can read and write concurrently without blocking each other. Each transaction sees a consistent snapshot of the database from when it began.
impl DocumentVersion { pub fn is_visible(&self, tx_id: TransactionID) -> bool { // Too new? Not created yet in this snapshot if self.xmin > tx_id { return false; } // Deleted before this snapshot? if self.xmax != 0 && self.xmax <= tx_id { return false; } true // Created before snapshot, not deleted yet }}
Track original xmin in doc_original_xmin for conflict detection
Mark that document existed in snapshot (doc_existed_in_snapshot)
Create new version
Allocate new page for document
Write document with xmin=11 (this transaction’s ID), xmax=0
Insert into transaction’s write buffer
On commit
Check for conflicts: has the committed version’s xmin changed?
Write new version to B-tree
Add old version (xmin=10, xmax=11) to version chain
Update B-tree root atomically
Why track doc_original_xmin?
Conflict detection compares the xmin we first read against the current committed xmin:
let original_xmin = 10; // What we read at the startlet committed_xmin = 12; // What's in the B-tree nowif committed_xmin != original_xmin && committed_xmin > snapshot_id { return Err(Error::TxConflict); // Someone modified it!}
This implements first-committer-wins: if another transaction committed an update after our snapshot, we conflict.
The GC process (src/core/mvcc.rs:172) works as follows:
pub fn garbage_collect(&self, oldest_active_tx: TransactionID) -> Result<Vec<DocumentVersion>> { let mut versions = self.versions.write()?; let mut removed = Vec::new(); let mut kept = Vec::new(); for version in versions.drain(..) { if version.xmin >= oldest_active_tx || // Version created by active TX (version.xmax == 0 || version.xmax >= oldest_active_tx) { // Still visible kept.push(version); } else { removed.push(version); // No one can see this - remove it } } *versions = kept; Ok(removed)}
Key insight: A version can be removed if:
It was created AND deleted before the oldest active transaction
Long-running transactions prevent GC: If a transaction stays open for a long time, its snapshot_id becomes the “oldest active”, preventing all versions created after it from being cleaned up. Keep transactions short!
The B-tree only stores the current version of each document (the one with xmax=0). Old versions live in version chains.When a transaction reads a document:
Search B-tree for document ID → get current version
Check visibility of current version
If visible → return it
If not visible → search version chain for visible version
This design optimizes for the common case: most reads access the current version, requiring only a B-tree lookup. Version chain traversal only happens for reading old snapshots.
Now we can understand conflict detection in detail (src/core/transaction.rs:357):
fn detect_write_conflicts(&self) -> Result<()> { for (doc_id, _) in collection_writes.iter() { // Skip new inserts (optimization) if !doc_existed_in_snapshot.get(doc_id).unwrap_or(false) { continue; } // Get xmin we saw when we first read let original_xmin = doc_original_xmin.get(doc_id)?; // Read CURRENT committed version (not our write) let current_btree = TxBTree::new(pager, current_root, empty_writes); let committed_page = current_btree.search(doc_id)?; let committed_vdoc = read_versioned_document(pager, committed_page)?; // Conflict if xmin changed AND new version is after our snapshot if committed_vdoc.xmin != original_xmin { if committed_vdoc.xmin > self.snapshot_id { return Err(Error::TxConflict); } } } Ok(())}
Why check xmin > snapshot_id?If the new xmin is ≤ our snapshot, it means the update was committed before we started, so we should have seen it. No conflict. But if xmin > snapshot, it means someone committed after we began, which is a write-write conflict.
jasonisnthappy provides snapshot isolation, which prevents:✓ Dirty reads - Never see uncommitted changes
✓ Non-repeatable reads - Snapshot is consistent throughout transaction
✓ Phantom reads - Snapshot includes consistent set of documents
✓ Lost updates - Conflict detection prevents concurrent overwritesBut allows:✗ Write skew - Two transactions read overlapping data, write disjoint dataExample of write skew:
// Initial: alice.balance=100, bob.balance=100, total must be >= 100// TX1: Transfer from Alicelet alice = users.find_one("alice")?; // 100let bob = users.find_one("bob")?; // 100if alice.balance + bob.balance >= 100 { alice.balance -= 50; // Now 50 users.update("alice", alice)?;}// TX2: Transfer from Bob (concurrent)let alice = users.find_one("alice")?; // 100 (old snapshot)let bob = users.find_one("bob")?; // 100if alice.balance + bob.balance >= 100 { bob.balance -= 50; // Now 50 users.update("bob", bob)?;}// Both commit! Total is now 100, but invariant was violated mid-flight
Both transactions commit because they modify different documents (no write-write conflict). But the total balance invariant is violated.
Workaround: Use a single “accounts” document to track totals, or model the constraint as a write to a shared document that forces conflict detection.