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 need | use |
|---|---|
| data to survive a restart | an embedded database — redb, sled, SQLite |
| more data than fits in RAM | anything with a buffer pool |
| multiple processes | a real database server |
| one map, one thread at a time | RwLock<HashMap<K, V>> |
| queries by shape rather than by key | a 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.
| level | sees | permits |
|---|---|---|
ReadCommitted | a fresh snapshot per statement | non-repeatable reads, phantoms |
RepeatableRead | one snapshot | write skew |
Snapshot (default) | one snapshot | write skew |
Serializable | one snapshot + conflict detection | nothing |
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.rsis 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
watermarkandactive_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. - Read
Committed - Each statement sees everything committed before that statement started.
- Ref
- A snapshot-consistent view of a record.
- Repeatable
Read - 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
Timestampso that ids and timestamps never collide.
Enums§
- Error
- Everything an engine operation can fail with.
Traits§
- Isolation
Level - A transaction isolation level.
- Versioned
- A struct that the engine can store and version.
Type Aliases§
Derive Macros§
- Mvcc
- Make a struct storable in a
Database, by implementingVersionedfor it.