Transactions
Snapshot isolation by default, serializable on request: reads at one snapshot plus the transaction's own writes, savepoints, and an atomic commit that refuses a conflict without taking a lock.
A transaction reads at its own snapshot, keeps its writes private, and commits them as one batch.
A transaction
use tessarikv::{Db, Error, KeyspaceId, ReadOptions, WriteOptions};
async fn reserve(db: &Db) -> tessarikv::Result<bool> {
let mut transaction = db.begin()?;
let stock = transaction.get(KeyspaceId::DEFAULT, b"sku:1001", &ReadOptions::default()).await?;
if stock.is_none() {
return Ok(false); // dropping the transaction discards it
}
transaction.put(KeyspaceId::DEFAULT, b"reservation:78", b"sku:1001")?;
match transaction.commit(&WriteOptions::default()).await {
Ok(_sequence) => Ok(true),
// Someone wrote a key this transaction writes after it began. Nothing was written.
Err(Error::Conflict) => Ok(false),
Err(other) => Err(other),
}
}
- Reads (
get,multi_get,scan,multi_scan) see the snapshot the transaction began at, plus its own writes. - Writes (
put,delete,delete_range) stay private until the commit. commit(&WriteOptions)writes everything as one atomic batch, with the durability the options name, and returns its sequence number.- Dropping a transaction without committing discards it.
Conflicts: first committer wins
When a transaction commits, it checks whether any key it writes was written by
someone else after it began. If so, the commit is refused with Error::Conflict
and writes nothing. Run the transaction again from begin; Error::is_retryable
is true for a conflict.
The check takes no lock. A commit waits only for commits that claimed earlier sequence numbers to publish their keys.
Isolation levels
Isolation | Protects against | Admits |
|---|---|---|
Snapshot (default) | lost updates: two transactions writing the same key | write skew: two transactions each reading what the other writes |
Serializable | write skew too: the commit is also refused when a key it read, or a key in a range it scanned, was written after it began | nothing: committed transactions behave as if they ran one at a time, in commit order |
Serializable transactions need DbOptions::serializable_transactions set when
the database opens. Every write then also records the prefixes of the keys it
writes, so a transaction can tell whether a range it scanned changed. That
costs every write a few more atomic updates.
use tessarikv::{Db, DbOptions, Isolation, TransactionOptions};
async fn open_serializable() -> tessarikv::Result<Db> {
let options = DbOptions::builder().serializable_transactions(true).build()?;
Db::open("data/ledger", options).await
}
fn serializable() -> TransactionOptions {
let mut options = TransactionOptions::default();
options.isolation = Isolation::Serializable;
options
}
Begin one with db.begin_with(&serializable())?.
Savepoints
savepoint() marks the transaction's writes so far. rollback_to(savepoint)
discards every write made after it and keeps the ones before.
use tessarikv::{Db, KeyspaceId, WriteOptions};
async fn partial(db: &Db) -> tessarikv::Result<()> {
let mut transaction = db.begin()?;
transaction.put(KeyspaceId::DEFAULT, b"order:9", b"placed")?;
let mark = transaction.savepoint();
transaction.put(KeyspaceId::DEFAULT, b"coupon:9", b"applied")?;
transaction.rollback_to(mark); // the coupon is gone, the order stays
transaction.commit(&WriteOptions::default()).await?;
Ok(())
}
A transaction holds its snapshot until it commits or is dropped, so a long-lived one keeps old versions on disk. See Reads and snapshots.