Skip to main content

Crate mvcc

Crate mvcc 

Expand description

Transactions, isolation levels, and enforced constraints for ordinary Rust structs.

This is a database’s concurrency control without a database’s storage layer: atomicity, consistency, and isolation, but no durability. Nothing is ever written to disk. That is a deliberate trade — see What this is not.

Add Mvcc to a struct, register it with a Database, and several threads can read and write it through Transactions that each see a consistent snapshot of the whole database.

§The problem it solves

The honest comparison is not against Postgres, it is against RwLock<HashMap<K, V>>.

A lock gives you one map at a time. The moment an invariant spans two maps — or two records in one map — you need a consistent view across both, and the only way to get one from a lock is to hold it globally, which serialises every reader against every writer. Long reads make it worse: an analytics scan holding a read lock blocks writers for its whole duration.

Multi-version concurrency control removes that trade. Reach for this crate when you have shared mutable state that several threads read and write, and invariants that span more than one record. If you have one map and no cross-record invariant, the lock is simpler and you should use it.

you needuse
data to survive a restartan embedded database — redb, sled, SQLite
more data than fits in RAManything with a buffer pool
multiple processesa real database server
one map, one thread at a timeRwLock<HashMap<K, V>>
queries by shape rather than by keya query engine; this has no planner

§How it works

The core idea: never overwrite data, and never block a reader. An update writes a new version and leaves the old one in place. Each record is a slot holding a chain of versions, newest first:

index ──► Slot ──► Version { begin: 40, end: MAX, value }   ← current
                        │
                        ▼
                   Version { begin: 20, end: 40,  value }
                        │
                        ▼
                   Version { begin:  5, end: 20,  value }

A version is visible to a snapshot s when begin <= s < end. That is the whole visibility rule, and it is why the isolation levels differ only in which snapshot they pass in.

A transaction takes a snapshot timestamp when it begins and reads at it for its whole life, so its view never changes underneath it, no matter what commits alongside. Reading is a pointer walk and a comparison — no locks, no reference counts, and no writes to shared memory at all, which is why contended reads scale rather than collapse.

Writing is first-updater-wins: a writer claims the slot, and a second writer fails immediately with Error::WriteConflict rather than waiting. Waiting would reintroduce deadlock detection, which is one of the things MVCC removes. A delete installs a tombstone version rather than removing anything, so a reader at an older snapshot still finds the record alive.

§Getting started

Declare the record, register it, and do the work inside a transaction:

use mvcc::{Config, Database, Mvcc, Serializable};

#[derive(Mvcc, Clone, Debug)]
#[mvcc(table = "accounts")]
struct Account {
    #[mvcc(primary_key)]
    id: u64,

    /// No two accounts may share an owner; the engine enforces it.
    #[mvcc(index(unique))]
    owner: String,

    balance: i64,
}

let db = Database::open(Config::in_memory())?;
db.register::<Account>()?;

db.transaction(|tx| {
    tx.insert(Account { id: 1, owner: "ada".into(), balance: 100 })?;
    tx.insert(Account { id: 2, owner: "bob".into(), balance: 0 })
})?;

// This transfer's *write* depends on a balance it *read*, so it needs the
// strongest level — see below.
let moved = db.transaction_with::<Serializable, _, _>(|tx| {
    let balance = tx.get::<Account>(&1)?.map_or(0, |a| a.balance);
    if balance < 50 {
        return Ok(false);
    }
    tx.update::<Account>(&1, |a| a.balance -= 50)?;
    tx.update::<Account>(&2, |a| a.balance += 50)?;
    Ok(true)
})?;

assert!(moved);

let mut tx = db.begin();
assert_eq!(tx.get::<Account>(&2)?.unwrap().balance, 50);

Database::transaction is the API to reach for: it runs the closure, commits it, and re-runs it on a retriable conflict. Because it may run more than once, the closure must not have side effects outside the transaction — return the value and let the caller act on the committed result, as above. For manual control, Database::begin hands back a transaction you commit yourself, and dropping it without committing rolls it back.

§Isolation levels

The level is a type parameter, not a runtime flag, so the cost of the strongest never leaks into the weakest: a ReadCommitted transaction records no read set and allocates nothing.

levelseespermits
ReadCommitteda fresh snapshot per statementnon-repeatable reads, phantoms
RepeatableReadone snapshotwrite skew
Snapshot (default)one snapshotwrite skew
Serializableone snapshot + conflict detectionnothing

Default to Snapshot. Reads never block and never abort, and lost updates are impossible.

Reach for Serializable when a transaction’s write depends on something it merely read — balance checks, capacity limits, “at least one of these must remain true”. That is the write-skew shape, and snapshot isolation will not catch it: two transfers can each read a sufficient balance and both withdraw, because on the write side they touch different rows and so nothing conflicts. Expect retriable aborts in exchange.

Isolation behaviour is verified against Hermitage, Martin Kleppmann’s isolation test suite: all ten anomalies, each asserted present or absent per level.

§Conflicts are normal

A conflict is the engine reporting that two transactions could not both happen. Error::is_retriable separates the two cases: WriteConflict and SerializationFailure mean re-run, and everything else is a programming mistake that will fail again identically. Database::transaction already loops on the retriable ones, so most code never matches on this at all.

When scanning, prefer Transaction::scan_where over scan plus .filter(). The first hands the predicate to the engine, which re-evaluates it at commit and so can detect a row that appears and matches — a phantom. The second says only that you read the entire table, so any concurrent write to it aborts you.

§Memory growth

Superseded versions are reclaimed. When a write commits it also prunes the record’s chain, freeing every version no live transaction can still reach, so steady-state memory tracks live data rather than cumulative writes.

Reclamation is bounded below by the oldest live transaction. The cutoff is a minimum over live snapshots, so one forgotten transaction — a REPL session, a leaked handle, a long-running scan — pins it and version chains grow without limit for as long as it is open. It presents as a memory leak rather than as a transaction problem, and it is the most common way real MVCC systems fall over. Database::stats exposes the watermark and the live transaction count; watch them, and see stats::GcStats.

Deleting a record eventually returns everything it held: once the tombstone is itself below the watermark, the whole chain goes. And records that stop being written are collected too, by a sweep that rides on other commits — so a record written once and then only read does not keep its history forever.

What is not reclaimed is the per-key slot: roughly 180 bytes for every distinct key the database has ever held, measured with a counting allocator. That figure is flat in the size of the record — everything that scales with your type lives in the version, which is freed — so a workload churning through unboundedly many distinct keys still grows, but at a fixed cost per key rather than per byte written.

Database::compact gives those bytes back. Call it in a quiet moment if your key space is unbounded.

§What this is not

It is not durable, by design. Everything lives in memory and nothing survives the process — no log, no checkpoint, no data_dir, no recovery.

It is also not distributed — one process, one machine — and the dataset must fit in memory. There is no query planner: records are reached by primary key, by predicate, or by a range over a declared index.

§Where to look next

  • Mvcc — declaring a record, and the full attribute reference.
  • Transaction — reading, writing, and scanning.
  • Database — opening, registering types, running transactions.
  • Error — what can fail, and which failures are worth retrying.
  • The examples/ directory in the repository is the fastest way in. Each example ends by asserting that the world it built is still consistent; game.rs is the end-to-end tour, covering a write conflict, write skew and its fix, a phantom, an atomic trade, a long report reading while the world moves, and a four-thread raid.

Modules§

config
Tuning knobs.
stats
Runtime statistics. Watch watermark and active_transactions — see the garbage collection notes for why.

Structs§

Config
Configuration for a Database.
Database
Owns every table, version chain and index, and hands out Transactions.
Index
A handle to one of T’s secondary indexes, naming the field it covers and the type of that field.
ReadCommitted
Each statement sees everything committed before that statement started.
Ref
A snapshot-consistent view of a record.
RepeatableRead
A single snapshot for the whole transaction; repeated reads of the same key return the same value.
Serializable
Serializable, via SSI on top of snapshot isolation.
Snapshot
Full snapshot isolation. The default.
Timestamp
A point on the logical commit timeline.
Transaction
A transaction at isolation level I.
TxnId
Identifies a running transaction. Drawn from the same counter as Timestamp so that ids and timestamps never collide.

Enums§

Error
Everything an engine operation can fail with.

Traits§

IsolationLevel
A transaction isolation level.
Versioned
A struct that the engine can store and version.

Type Aliases§

Result
Result with this crate’s Error as the default error type.

Derive Macros§

Mvcc
Make a struct storable in a Database, by implementing Versioned for it.