An embedded, in-memory, lock-free, transaction-based, key-value database engine
See the codeAn embedded, in-memory, lock-free, transaction-based, key-value database engine.
SurrealMX is an ultra-high-throughput, in-memory, lock-free key-value database engine built on multi-version concurrency control (MVCC) over a concurrent adaptive radix tree, with a circular commit ring buffer.
It is designed as an independent, standalone embedded storage engine and caching layer suitable for microsecond-latency workloads, high-concurrency server applications, and browser WebAssembly environments.
Benchmarked on bare metal (AMD Ryzen Threadripper 9970X 32-Core / 64-Thread Processor @ 5.48 GHz, 128 GB DDR5 RAM, 15,000,000 keys across 48 concurrent worker threads with 128 clients, and 5,000,000 iterations of each range scan, via crud-bench; each figure is the best of three runs of that benchmark):
| Engine | Point Read (OPS) | Create (OPS) | Update (OPS) | Delete (OPS) | Scan (OPS) |
|---|---|---|---|---|---|
| SurrealMX | |||||
| LMDB (In-Memory) | 1,226,897 | 232,805 | 241,431 | 177,198 | 1,188,365 |
| RocksDB (In-Memory) | 759,060 | 616,649 | 572,246 | 705,773 | 836,666 |
db.with_value): 17.5× faster than LMDB and 28.3× faster than RocksDB.select(*) limit(100) scans using lock-free version reads and zero-allocation cursor traversal (scan_with / keys_for_each): 15.2× faster than LMDB and 21.6× faster than RocksDB. See Scan Profile for other kinds of range scan.Range scans over the same dataset, as scans per second: limit and skip scans, and counts over the full key range.
| Engine | Limit (OPS) | Keys, Limit (OPS) | Skip + Limit (OPS) | Keys, Skip + Limit (OPS) | Full Count (OPS) |
|---|---|---|---|---|---|
| SurrealMX | 232,121 | 253,039 | 17.3 | ||
| LMDB (In-Memory) | 1,188,365 | 1,192,034 | |||
| RocksDB (In-Memory) | 836,666 | 837,377 | 47,671 | 47,816 | 5.0 |
select(*) limit(100) returns the first 100 key-value pairs of the range. Keys, Limit (select(id) limit(100)) returns only the keys.select(*) start(5000) limit(100) walks and discards 5,000 entries before returning 100. Keys, Skip + Limit returns only the keys.count() visits every key in the range.Resting and peak memory are the lowest and highest process memory sampled across the whole benchmark run.
| Engine | Resting Memory | Peak Memory |
|---|---|---|
| SurrealMX | 32.0 GiB | |
| LMDB (In-Memory) | 19.8 GiB | |
| RocksDB (In-Memory) | 26.3 GiB | 96.1 GiB |
db.get, db.set, db.del, db.put, db.exists)scan_with, scan_into, keys_for_each, keys_into)use surrealmx::{Database, DatabaseOptions};
fn main() {
// Create a database with custom settings
let opts = DatabaseOptions { pool_size: 128, ..Default::default() };
let db = Database::new_with_options(opts);
// Start a write transaction
let mut tx = db.transaction(true);
tx.put("key", "value").unwrap();
tx.commit().unwrap();
// Read the value back
let mut tx = db.transaction(false);
assert_eq!(tx.get("key").unwrap(), Some("value".into()));
tx.cancel().unwrap();
}
For single-key reads, writes, and scans, Database exposes direct methods. Point reads (get, exists, with_value) bypass transaction pool checkout and reader slot registration entirely, and are linearizable: each returns the latest committed value. Writes commit as single-key auto-committed transactions with full snapshot isolation, retrying write-write conflicts internally since they read nothing. Scans read a consistent snapshot pinned by a pooled read transaction:
use surrealmx::Database;
fn main() {
let db = Database::new();
// Direct auto-committed writes
db.set("user:01", "alice").unwrap();
db.put("user:02", "bob").unwrap(); // Fails if key already exists
// Direct point reads
assert!(db.exists("user:01").unwrap());
assert_eq!(db.get("user:01").unwrap(), Some("alice".into()));
// Zero-copy value inspection via closure without cloning
db.with_value("user:01", |bytes| {
assert_eq!(bytes, b"alice");
}).unwrap();
// Direct deletion
db.del("user:02").unwrap();
assert!(!db.exists("user:02").unwrap());
}
Background worker threads perform cleanup and garbage collection at regular
intervals. These workers can be disabled through DatabaseOptions by setting
enable_cleanup or enable_gc to false. When disabled, trigger the tasks
manually: run_cleanup trims the transaction commit queue, run_gc_tracked
sweeps just the keys tracked as holding reclaimable version garbage (cheap,
its cost scales with the amount of pinned garbage, not the dataset size), and
run_gc performs a full datastore scan, useful as an occasional deep sweep.
use surrealmx::{Database, DatabaseOptions};
fn main() {
// Create a database with custom settings
let opts = DatabaseOptions { enable_gc: false, enable_cleanup: false, ..Default::default() };
let db = Database::new_with_options(opts);
// Start a write transaction
let mut tx = db.transaction(true);
tx.put("key", "value1").unwrap();
tx.commit().unwrap();
// Start a write transaction
let mut tx = db.transaction(true);
tx.put("key", "value2").unwrap();
tx.commit().unwrap();
// Manually trim the transaction commit queue
db.run_cleanup();
// Manually sweep the tracked garbage-candidate keys
db.run_gc_tracked();
// Occasionally, perform a full-scan sweep of the datastore
db.run_gc();
}
SurrealMX defaults to a purely in-memory engine (AolMode::Never, SnapshotMode::Never). Optional durability is enabled by providing [PersistenceOptions] to [Database::new_with_persistence].
Provides durability by logging every change to an append-only log (AOL) and taking periodic point-in-time snapshots.
use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, SnapshotMode};
use std::time::Duration;
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./data")
.with_aol_mode(AolMode::SynchronousOnCommit)
.with_snapshot_mode(SnapshotMode::Interval(Duration::from_secs(60)));
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Changes immediately written to AOL
Ok(())
}
Provides good performance with periodic durability by taking snapshots without logging individual changes.
use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, SnapshotMode};
use std::time::Duration;
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./snapshot_data")
.with_aol_mode(AolMode::Never) // Disable AOL, use only snapshots
.with_snapshot_mode(SnapshotMode::Interval(Duration::from_secs(30)));
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Changes only persisted during snapshots
Ok(())
}
AolMode::Never: Disables append-only logging entirely (default)AolMode::SynchronousOnCommit: Writes changes to AOL immediately on every commit (maximum durability)AolMode::AsynchronousAfterCommit: Writes changes to AOL asynchronously after every commit (better performance)SnapshotMode::Never: Disables snapshots entirely (default)SnapshotMode::Interval(Duration): Takes snapshots at the specified intervalFsyncMode::Never: Never calls fsync - fastest but least durable (default)FsyncMode::EveryAppend: Calls fsync after every AOL append - slowest but most durableFsyncMode::Interval(Duration): Calls fsync at most once per interval - balanced approachCompressionMode::None: No compression applied to snapshots (default)CompressionMode::Lz4: Fast LZ4 compression for snapshots (reduces storage size)use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, SnapshotMode, FsyncMode, CompressionMode};
use std::time::Duration;
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./advanced_data")
.with_aol_mode(AolMode::AsynchronousAfterCommit) // Async AOL writes
.with_snapshot_mode(SnapshotMode::Interval(Duration::from_secs(300))) // Snapshot every 5 minutes
.with_fsync_mode(FsyncMode::Interval(Duration::from_secs(1))) // Fsync every second
.with_compression(CompressionMode::Lz4); // Enable LZ4 compression
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Changes written asynchronously to AOL, fsync'd every second
Ok(())
}
Trade-offs:
See the Durability guarantees section for detailed information about ACID durability levels.
SurrealMX provides different levels of durability (the "D" in ACID) depending on the persistence configuration:
When persistence is disabled (the default), SurrealMX provides no durability guarantees. All data is lost when the process terminates, crashes, or the system shuts down. This mode is ideal for:
For maximum durability that survives system crashes and power failures, use synchronous AOL with fsync on every append:
use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, FsyncMode};
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./data")
.with_aol_mode(AolMode::SynchronousOnCommit)
.with_fsync_mode(FsyncMode::EveryAppend);
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Guaranteed to be durable after this returns
Ok(())
}
With this configuration:
fsync() is called to ensure data reaches physical storagecommit() returns successfullyDifferent persistence configurations provide different durability guarantees:
AOL Modes:
AolMode::SynchronousOnCommit: Changes written to AOL immediately on commit. Durable after commit returns (if combined with appropriate fsync mode).AolMode::AsynchronousAfterCommit: Changes written to AOL asynchronously. Small window where recent commits may be lost on sudden system crash.AolMode::Never: No AOL logging. Changes only persisted via snapshots.Fsync Modes:
FsyncMode::EveryAppend: Calls fsync() after every AOL write. Maximum durability but slowest performance.FsyncMode::Interval(Duration): Calls fsync() periodically. Durability guaranteed after the interval passes.FsyncMode::Never: Never calls fsync(). Relies on OS to flush data. Risk of data loss if OS crashes before flush.Snapshot Modes:
SnapshotMode::Interval(Duration): Takes periodic snapshots. Without AOL, only data from the last snapshot is durable.SnapshotMode::Never: No snapshots. Must use AOL for any durability.Durability guarantees summary:
| Configuration | Survives Process Crash | Survives System Crash | Performance |
|---|---|---|---|
| No persistence | ❌ | ❌ | Fastest |
| Snapshot-only | ⚠️ (last snapshot) | ⚠️ (last snapshot) | Fastest |
| Async AOL + No fsync | ⚠️ (mostly) | ⚠️ (mostly + OS buffers) | Very fast |
| Async AOL + Interval fsync | ⚠️ (mostly) | ⚠️ (mostly + since last fsync) | Very fast |
| Async AOL + Every fsync | ⚠️ (mostly) | ⚠️ (mostly) | Very fast |
| Sync AOL + No fsync | ✅ | ⚠️ (OS buffers) | Fast |
| Sync AOL + Interval fsync | ✅ | ⚠️ (since last fsync) | Fast |
| Sync AOL + Every fsync | ✅ | ✅ | Slow |
Each committed transaction is written to the AOL as a single checksummed frame, so a crash part-way through a write never leaves a transaction half-applied: after a restart a transaction is either fully present or absent.
When a database is opened, an incomplete frame at the end of the AOL is discarded and cut from the file before any new commit is appended, so commits made after a recovery are not lost behind the damage. Damage which is followed by intact data is not treated as a torn tail: opening fails with a Corrupted persistence error instead of silently dropping the commits that came after it.
When a database using AolMode::AsynchronousAfterCommit is dropped, commits still queued for the AOL are written before it shuts down.
AOL files written by earlier releases are still read. The first time this release opens one, it marks the end of the old records, and everything it appends from then on is framed. Earlier releases refuse to open an AOL which holds framed transactions.
Choose the configuration that best balances your durability requirements against performance needs.
SurrealMX provides two complementary isolation guarantees based on transaction intent:
db.transaction(true)): Execute with Strict Serializable guarantees. All reads (via get, getm, scan, etc.) and writes are tracked. At commit time, the transaction is conflict-checked against all concurrent commits occurring during its lifetime, preventing write-write conflicts (KeyWriteConflict), read-write anti-dependencies (KeyReadConflict), and write skew.db.transaction(false)): Execute with Snapshot Isolation. They observe a completely consistent, linearizable point-in-time cut of the database pinned at transaction creation without taking read locks.Read-only transactions see a consistent snapshot of the database as it existed when the transaction began, with zero locking overhead.
use surrealmx::Database;
fn main() {
let db = Database::new();
// Start an update transaction and a read-only transaction
let mut tx1 = db.transaction(true);
let mut tx2 = db.transaction(false); // Start tx2 before tx1 commits
tx1.put("counter", "1").unwrap();
tx1.commit().unwrap();
// tx2 started before tx1 committed, so it doesn't see the change
assert_eq!(tx2.get("counter").unwrap(), None);
tx2.cancel().unwrap();
}
Update transactions provide strict serializability by detecting read-write conflicts and aborting transactions that would violate serializability.
Error::KeyReadConflict] or [Error::KeyWriteConflict] and can be safely retrieduse surrealmx::{Database, Error};
fn main() {
let db = Database::new();
// Initialize data
let mut tx = db.transaction(true);
tx.put("x", "0").unwrap();
tx.put("y", "0").unwrap();
tx.commit().unwrap();
// Two concurrent transactions that would cause write skew
let mut tx1 = db.transaction(true); // Uses SerializableSnapshotIsolation internally
let mut tx2 = db.transaction(true);
// tx1 reads x and writes to y
tx1.get("x").unwrap();
tx1.set("y", "modified_by_tx1").unwrap();
// tx2 reads y and writes to x
tx2.get("y").unwrap();
tx2.set("x", "modified_by_tx2").unwrap();
// First transaction commits successfully
tx1.commit().unwrap();
// Second transaction detects conflict and aborts
match tx2.commit() {
Err(Error::KeyReadConflict) => {
// Transaction must be retried
println!("Transaction aborted due to read conflict, retrying...");
}
_ => panic!("Expected read conflict"),
}
}
When to use each isolation level:
When a transaction is aborted due to a conflict (KeyReadConflict or KeyWriteConflict), SurrealMX can log the conflicting key for debugging purposes. These logs are emitted at the DEBUG level using the tracing crate, and are only included in debug builds (#[cfg(debug_assertions)]).
All conflict logs are emitted under a dedicated tracing target, exposed as the public constant [LOG_TARGET_CONFLICTS]:
use surrealmx::LOG_TARGET_CONFLICTS;
// Value: "surrealmx::conflicts"
This allows you to selectively enable conflict diagnostics with a tracing_subscriber filter, without enabling all DEBUG output from the crate:
use tracing_subscriber::EnvFilter;
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.parse("info,surrealmx::conflicts=debug")
.unwrap(),
)
.init();
Or via the RUST_LOG environment variable:
RUST_LOG="info,surrealmx::conflicts=debug" cargo run
Savepoints mark a point inside a transaction that later writes can be undone back to, without discarding the whole transaction. They are stackable, so scopes can nest.
let db = Database::new();
let mut tx = db.transaction(true);
tx.set("user:1", "alice").unwrap();
// Mark a point, then make some changes
tx.set_savepoint().unwrap();
tx.set("user:1", "bob").unwrap();
tx.set("user:2", "carol").unwrap();
// Undo everything back to the savepoint
tx.rollback_to_savepoint().unwrap();
assert_eq!(tx.get("user:1").unwrap(), Some("alice".into()));
assert_eq!(tx.get("user:2").unwrap(), None);
tx.commit().unwrap();
Every set_savepoint should be paired with exactly one of:
rollback_to_savepoint: undo every write made since the savepoint, and discard it.release_savepoint: keep every write made since the savepoint, and discard it.Releasing is what a nested scope does when it succeeds. Its writes join the enclosing scope, so a later rollback of that enclosing scope still undoes them:
tx.set_savepoint().unwrap(); // outer scope
tx.set("a", "1").unwrap();
tx.set_savepoint().unwrap(); // inner scope
tx.set("b", "2").unwrap();
tx.release_savepoint().unwrap(); // inner scope succeeded, so keep "b"
assert_eq!(tx.get("b").unwrap(), Some("2".into()));
// Unwinding the outer scope still undoes the released scope's write
tx.rollback_to_savepoint().unwrap();
assert_eq!(tx.get("a").unwrap(), None);
assert_eq!(tx.get("b").unwrap(), None);
Both methods return Error::NoSavepoint when no savepoint is set, and Error::TxNotWritable on a read-only transaction. Savepoints are anonymous, so releasing more savepoints than were set cannot be detected once the stack is non-empty again: a later rollback will silently unwind further than intended.
Note that a rollback rewinds writes only. Keys read and ranges scanned inside a rolled back scope stay tracked for conflict detection, because a write that survives the rollback may have been derived from a value that scope read. This keeps serializable transactions conservative: it can produce a retryable conflict error, never a missed one.
SurrealMX provides powerful range-based operations for scanning, counting, and iterating over keys. All range operations support:
scan_with, keys_for_each) inspecting borrowed slices directly without vector allocations or value clonesscan_into, keys_into) avoiding repeated vector reallocations across query loopsuse surrealmx::Database;
fn main() {
let db = Database::new();
// Insert test data
let mut tx = db.transaction(true);
for i in 1..=10 {
tx.put(&format!("key:{:02}", i), &format!("value:{}", i)).unwrap();
}
tx.commit().unwrap();
let mut tx = db.transaction(false);
// Get all keys in range
let keys = tx.keys("key:03".."key:08", None, None).unwrap();
assert_eq!(keys.len(), 5);
assert_eq!(keys[0].as_ref(), b"key:03");
assert_eq!(keys[4].as_ref(), b"key:07");
// Get key-value pairs in range
let pairs = tx.scan("key:03".."key:06", None, None).unwrap();
assert_eq!(pairs.len(), 3);
assert_eq!(pairs[0].0.as_ref(), b"key:03");
assert_eq!(pairs[0].1.as_ref(), b"value:3");
// Count keys in range
let count = tx.total("key:00".."key:99", None, None).unwrap();
assert_eq!(count, 10);
tx.cancel().unwrap();
}
use surrealmx::Database;
fn main() {
let db = Database::new();
// Insert test data
let mut tx = db.transaction(true);
for i in 1..=100 {
tx.put(format!("item:{:03}", i), format!("value_{}", i)).unwrap();
}
tx.commit().unwrap();
let mut tx = db.transaction(false);
// Paginated forward scan: skip 10, take 5
let page1 = tx.scan("item:000".."item:999", Some(10), Some(5)).unwrap();
assert_eq!(page1.len(), 5);
assert_eq!(page1[0].0.as_ref(), b"item:011");
assert_eq!(page1[4].0.as_ref(), b"item:015");
// Reverse iteration: get last 3 items
let last_items = tx.scan_reverse("item:000".."item:999", None, Some(3)).unwrap();
assert_eq!(last_items.len(), 3);
assert_eq!(last_items[0].0.as_ref(), b"item:100"); // First item is the highest key
assert_eq!(last_items[2].0.as_ref(), b"item:098"); // Last item is lower
tx.cancel().unwrap();
}
use surrealmx::Database;
fn main() {
let db = Database::new();
for i in 0..100 {
db.set(format!("doc:{i:04}"), format!("val_{i}")).unwrap();
}
// Inspect borrowed key (&ByteSlice) and value (&[u8]) directly with zero allocations
db.scan_with("doc:0000".."doc:9999", None, Some(10), |key, val| {
println!("{key:?} => {:?}", std::str::from_utf8(val).unwrap());
true // Return false to stop iteration early
}).unwrap();
// Scan keys with zero allocation
db.keys_for_each("doc:0000".."doc:9999", None, Some(10), |key| {
println!("Key: {key:?}");
true
}).unwrap();
// Reuse a pre-allocated vector buffer across repeated scans
let mut buffer = Vec::new();
db.scan_into("doc:0000".."doc:0050", None, None, &mut buffer).unwrap();
assert_eq!(buffer.len(), 50);
}
Available range operation methods:
scan_with(range, skip, limit, closure): Zero-allocation closure inspection over borrowed (&ByteSlice, &[u8])keys_for_each(range, skip, limit, closure): Zero-allocation closure iteration over borrowed &ByteSlice keysscan_into(range, skip, limit, &mut buffer): Reuses pre-allocated buffer for key-value pairskeys_into(range, skip, limit, &mut buffer): Reuses pre-allocated buffer for keyskeys(range, skip, limit) / keys_reverse(...): Get keys in range as Vec<ByteSlice>scan(range, skip, limit) / scan_reverse(...): Get key-value pairs in range as Vec<(ByteSlice, ByteSlice)>total(range, skip, limit): Count keys in rangeLicensed under the Apache License, Version 2.0. See LICENSE for details.
622 followers · starred Jul 2026
12 followers · starred Nov 2025
An embedded, in-memory, lock-free, transaction-based, key-value database engine
See the codeAn embedded, in-memory, lock-free, transaction-based, key-value database engine.
SurrealMX is an ultra-high-throughput, in-memory, lock-free key-value database engine built on multi-version concurrency control (MVCC) over a concurrent adaptive radix tree, with a circular commit ring buffer.
It is designed as an independent, standalone embedded storage engine and caching layer suitable for microsecond-latency workloads, high-concurrency server applications, and browser WebAssembly environments.
Benchmarked on bare metal (AMD Ryzen Threadripper 9970X 32-Core / 64-Thread Processor @ 5.48 GHz, 128 GB DDR5 RAM, 15,000,000 keys across 48 concurrent worker threads with 128 clients, and 5,000,000 iterations of each range scan, via crud-bench; each figure is the best of three runs of that benchmark):
| Engine | Point Read (OPS) | Create (OPS) | Update (OPS) | Delete (OPS) | Scan (OPS) |
|---|---|---|---|---|---|
| SurrealMX | |||||
| LMDB (In-Memory) | 1,226,897 | 232,805 | 241,431 | 177,198 | 1,188,365 |
| RocksDB (In-Memory) | 759,060 | 616,649 | 572,246 | 705,773 | 836,666 |
db.with_value): 17.5× faster than LMDB and 28.3× faster than RocksDB.select(*) limit(100) scans using lock-free version reads and zero-allocation cursor traversal (scan_with / keys_for_each): 15.2× faster than LMDB and 21.6× faster than RocksDB. See Scan Profile for other kinds of range scan.Range scans over the same dataset, as scans per second: limit and skip scans, and counts over the full key range.
| Engine | Limit (OPS) | Keys, Limit (OPS) | Skip + Limit (OPS) | Keys, Skip + Limit (OPS) | Full Count (OPS) |
|---|---|---|---|---|---|
| SurrealMX | 232,121 | 253,039 | 17.3 | ||
| LMDB (In-Memory) | 1,188,365 | 1,192,034 | |||
| RocksDB (In-Memory) | 836,666 | 837,377 | 47,671 | 47,816 | 5.0 |
select(*) limit(100) returns the first 100 key-value pairs of the range. Keys, Limit (select(id) limit(100)) returns only the keys.select(*) start(5000) limit(100) walks and discards 5,000 entries before returning 100. Keys, Skip + Limit returns only the keys.count() visits every key in the range.Resting and peak memory are the lowest and highest process memory sampled across the whole benchmark run.
| Engine | Resting Memory | Peak Memory |
|---|---|---|
| SurrealMX | 32.0 GiB | |
| LMDB (In-Memory) | 19.8 GiB | |
| RocksDB (In-Memory) | 26.3 GiB | 96.1 GiB |
db.get, db.set, db.del, db.put, db.exists)scan_with, scan_into, keys_for_each, keys_into)use surrealmx::{Database, DatabaseOptions};
fn main() {
// Create a database with custom settings
let opts = DatabaseOptions { pool_size: 128, ..Default::default() };
let db = Database::new_with_options(opts);
// Start a write transaction
let mut tx = db.transaction(true);
tx.put("key", "value").unwrap();
tx.commit().unwrap();
// Read the value back
let mut tx = db.transaction(false);
assert_eq!(tx.get("key").unwrap(), Some("value".into()));
tx.cancel().unwrap();
}
For single-key reads, writes, and scans, Database exposes direct methods. Point reads (get, exists, with_value) bypass transaction pool checkout and reader slot registration entirely, and are linearizable: each returns the latest committed value. Writes commit as single-key auto-committed transactions with full snapshot isolation, retrying write-write conflicts internally since they read nothing. Scans read a consistent snapshot pinned by a pooled read transaction:
use surrealmx::Database;
fn main() {
let db = Database::new();
// Direct auto-committed writes
db.set("user:01", "alice").unwrap();
db.put("user:02", "bob").unwrap(); // Fails if key already exists
// Direct point reads
assert!(db.exists("user:01").unwrap());
assert_eq!(db.get("user:01").unwrap(), Some("alice".into()));
// Zero-copy value inspection via closure without cloning
db.with_value("user:01", |bytes| {
assert_eq!(bytes, b"alice");
}).unwrap();
// Direct deletion
db.del("user:02").unwrap();
assert!(!db.exists("user:02").unwrap());
}
Background worker threads perform cleanup and garbage collection at regular
intervals. These workers can be disabled through DatabaseOptions by setting
enable_cleanup or enable_gc to false. When disabled, trigger the tasks
manually: run_cleanup trims the transaction commit queue, run_gc_tracked
sweeps just the keys tracked as holding reclaimable version garbage (cheap,
its cost scales with the amount of pinned garbage, not the dataset size), and
run_gc performs a full datastore scan, useful as an occasional deep sweep.
use surrealmx::{Database, DatabaseOptions};
fn main() {
// Create a database with custom settings
let opts = DatabaseOptions { enable_gc: false, enable_cleanup: false, ..Default::default() };
let db = Database::new_with_options(opts);
// Start a write transaction
let mut tx = db.transaction(true);
tx.put("key", "value1").unwrap();
tx.commit().unwrap();
// Start a write transaction
let mut tx = db.transaction(true);
tx.put("key", "value2").unwrap();
tx.commit().unwrap();
// Manually trim the transaction commit queue
db.run_cleanup();
// Manually sweep the tracked garbage-candidate keys
db.run_gc_tracked();
// Occasionally, perform a full-scan sweep of the datastore
db.run_gc();
}
SurrealMX defaults to a purely in-memory engine (AolMode::Never, SnapshotMode::Never). Optional durability is enabled by providing [PersistenceOptions] to [Database::new_with_persistence].
Provides durability by logging every change to an append-only log (AOL) and taking periodic point-in-time snapshots.
use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, SnapshotMode};
use std::time::Duration;
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./data")
.with_aol_mode(AolMode::SynchronousOnCommit)
.with_snapshot_mode(SnapshotMode::Interval(Duration::from_secs(60)));
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Changes immediately written to AOL
Ok(())
}
Provides good performance with periodic durability by taking snapshots without logging individual changes.
use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, SnapshotMode};
use std::time::Duration;
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./snapshot_data")
.with_aol_mode(AolMode::Never) // Disable AOL, use only snapshots
.with_snapshot_mode(SnapshotMode::Interval(Duration::from_secs(30)));
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Changes only persisted during snapshots
Ok(())
}
AolMode::Never: Disables append-only logging entirely (default)AolMode::SynchronousOnCommit: Writes changes to AOL immediately on every commit (maximum durability)AolMode::AsynchronousAfterCommit: Writes changes to AOL asynchronously after every commit (better performance)SnapshotMode::Never: Disables snapshots entirely (default)SnapshotMode::Interval(Duration): Takes snapshots at the specified intervalFsyncMode::Never: Never calls fsync - fastest but least durable (default)FsyncMode::EveryAppend: Calls fsync after every AOL append - slowest but most durableFsyncMode::Interval(Duration): Calls fsync at most once per interval - balanced approachCompressionMode::None: No compression applied to snapshots (default)CompressionMode::Lz4: Fast LZ4 compression for snapshots (reduces storage size)use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, SnapshotMode, FsyncMode, CompressionMode};
use std::time::Duration;
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./advanced_data")
.with_aol_mode(AolMode::AsynchronousAfterCommit) // Async AOL writes
.with_snapshot_mode(SnapshotMode::Interval(Duration::from_secs(300))) // Snapshot every 5 minutes
.with_fsync_mode(FsyncMode::Interval(Duration::from_secs(1))) // Fsync every second
.with_compression(CompressionMode::Lz4); // Enable LZ4 compression
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Changes written asynchronously to AOL, fsync'd every second
Ok(())
}
Trade-offs:
See the Durability guarantees section for detailed information about ACID durability levels.
SurrealMX provides different levels of durability (the "D" in ACID) depending on the persistence configuration:
When persistence is disabled (the default), SurrealMX provides no durability guarantees. All data is lost when the process terminates, crashes, or the system shuts down. This mode is ideal for:
For maximum durability that survives system crashes and power failures, use synchronous AOL with fsync on every append:
use surrealmx::{Database, DatabaseOptions, PersistenceOptions, AolMode, FsyncMode};
fn main() -> std::io::Result<()> {
let db_opts = DatabaseOptions::default();
let persistence_opts = PersistenceOptions::new("./data")
.with_aol_mode(AolMode::SynchronousOnCommit)
.with_fsync_mode(FsyncMode::EveryAppend);
let db = Database::new_with_persistence(db_opts, persistence_opts)?;
let mut tx = db.transaction(true);
tx.put("key", "value")?;
tx.commit()?; // Guaranteed to be durable after this returns
Ok(())
}
With this configuration:
fsync() is called to ensure data reaches physical storagecommit() returns successfullyDifferent persistence configurations provide different durability guarantees:
AOL Modes:
AolMode::SynchronousOnCommit: Changes written to AOL immediately on commit. Durable after commit returns (if combined with appropriate fsync mode).AolMode::AsynchronousAfterCommit: Changes written to AOL asynchronously. Small window where recent commits may be lost on sudden system crash.AolMode::Never: No AOL logging. Changes only persisted via snapshots.Fsync Modes:
FsyncMode::EveryAppend: Calls fsync() after every AOL write. Maximum durability but slowest performance.FsyncMode::Interval(Duration): Calls fsync() periodically. Durability guaranteed after the interval passes.FsyncMode::Never: Never calls fsync(). Relies on OS to flush data. Risk of data loss if OS crashes before flush.Snapshot Modes:
SnapshotMode::Interval(Duration): Takes periodic snapshots. Without AOL, only data from the last snapshot is durable.SnapshotMode::Never: No snapshots. Must use AOL for any durability.Durability guarantees summary:
| Configuration | Survives Process Crash | Survives System Crash | Performance |
|---|---|---|---|
| No persistence | ❌ | ❌ | Fastest |
| Snapshot-only | ⚠️ (last snapshot) | ⚠️ (last snapshot) | Fastest |
| Async AOL + No fsync | ⚠️ (mostly) | ⚠️ (mostly + OS buffers) | Very fast |
| Async AOL + Interval fsync | ⚠️ (mostly) | ⚠️ (mostly + since last fsync) | Very fast |
| Async AOL + Every fsync | ⚠️ (mostly) | ⚠️ (mostly) | Very fast |
| Sync AOL + No fsync | ✅ | ⚠️ (OS buffers) | Fast |
| Sync AOL + Interval fsync | ✅ | ⚠️ (since last fsync) | Fast |
| Sync AOL + Every fsync | ✅ | ✅ | Slow |
Each committed transaction is written to the AOL as a single checksummed frame, so a crash part-way through a write never leaves a transaction half-applied: after a restart a transaction is either fully present or absent.
When a database is opened, an incomplete frame at the end of the AOL is discarded and cut from the file before any new commit is appended, so commits made after a recovery are not lost behind the damage. Damage which is followed by intact data is not treated as a torn tail: opening fails with a Corrupted persistence error instead of silently dropping the commits that came after it.
When a database using AolMode::AsynchronousAfterCommit is dropped, commits still queued for the AOL are written before it shuts down.
AOL files written by earlier releases are still read. The first time this release opens one, it marks the end of the old records, and everything it appends from then on is framed. Earlier releases refuse to open an AOL which holds framed transactions.
Choose the configuration that best balances your durability requirements against performance needs.
SurrealMX provides two complementary isolation guarantees based on transaction intent:
db.transaction(true)): Execute with Strict Serializable guarantees. All reads (via get, getm, scan, etc.) and writes are tracked. At commit time, the transaction is conflict-checked against all concurrent commits occurring during its lifetime, preventing write-write conflicts (KeyWriteConflict), read-write anti-dependencies (KeyReadConflict), and write skew.db.transaction(false)): Execute with Snapshot Isolation. They observe a completely consistent, linearizable point-in-time cut of the database pinned at transaction creation without taking read locks.Read-only transactions see a consistent snapshot of the database as it existed when the transaction began, with zero locking overhead.
use surrealmx::Database;
fn main() {
let db = Database::new();
// Start an update transaction and a read-only transaction
let mut tx1 = db.transaction(true);
let mut tx2 = db.transaction(false); // Start tx2 before tx1 commits
tx1.put("counter", "1").unwrap();
tx1.commit().unwrap();
// tx2 started before tx1 committed, so it doesn't see the change
assert_eq!(tx2.get("counter").unwrap(), None);
tx2.cancel().unwrap();
}
Update transactions provide strict serializability by detecting read-write conflicts and aborting transactions that would violate serializability.
Error::KeyReadConflict] or [Error::KeyWriteConflict] and can be safely retrieduse surrealmx::{Database, Error};
fn main() {
let db = Database::new();
// Initialize data
let mut tx = db.transaction(true);
tx.put("x", "0").unwrap();
tx.put("y", "0").unwrap();
tx.commit().unwrap();
// Two concurrent transactions that would cause write skew
let mut tx1 = db.transaction(true); // Uses SerializableSnapshotIsolation internally
let mut tx2 = db.transaction(true);
// tx1 reads x and writes to y
tx1.get("x").unwrap();
tx1.set("y", "modified_by_tx1").unwrap();
// tx2 reads y and writes to x
tx2.get("y").unwrap();
tx2.set("x", "modified_by_tx2").unwrap();
// First transaction commits successfully
tx1.commit().unwrap();
// Second transaction detects conflict and aborts
match tx2.commit() {
Err(Error::KeyReadConflict) => {
// Transaction must be retried
println!("Transaction aborted due to read conflict, retrying...");
}
_ => panic!("Expected read conflict"),
}
}
When to use each isolation level:
When a transaction is aborted due to a conflict (KeyReadConflict or KeyWriteConflict), SurrealMX can log the conflicting key for debugging purposes. These logs are emitted at the DEBUG level using the tracing crate, and are only included in debug builds (#[cfg(debug_assertions)]).
All conflict logs are emitted under a dedicated tracing target, exposed as the public constant [LOG_TARGET_CONFLICTS]:
use surrealmx::LOG_TARGET_CONFLICTS;
// Value: "surrealmx::conflicts"
This allows you to selectively enable conflict diagnostics with a tracing_subscriber filter, without enabling all DEBUG output from the crate:
use tracing_subscriber::EnvFilter;
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.parse("info,surrealmx::conflicts=debug")
.unwrap(),
)
.init();
Or via the RUST_LOG environment variable:
RUST_LOG="info,surrealmx::conflicts=debug" cargo run
Savepoints mark a point inside a transaction that later writes can be undone back to, without discarding the whole transaction. They are stackable, so scopes can nest.
let db = Database::new();
let mut tx = db.transaction(true);
tx.set("user:1", "alice").unwrap();
// Mark a point, then make some changes
tx.set_savepoint().unwrap();
tx.set("user:1", "bob").unwrap();
tx.set("user:2", "carol").unwrap();
// Undo everything back to the savepoint
tx.rollback_to_savepoint().unwrap();
assert_eq!(tx.get("user:1").unwrap(), Some("alice".into()));
assert_eq!(tx.get("user:2").unwrap(), None);
tx.commit().unwrap();
Every set_savepoint should be paired with exactly one of:
rollback_to_savepoint: undo every write made since the savepoint, and discard it.release_savepoint: keep every write made since the savepoint, and discard it.Releasing is what a nested scope does when it succeeds. Its writes join the enclosing scope, so a later rollback of that enclosing scope still undoes them:
tx.set_savepoint().unwrap(); // outer scope
tx.set("a", "1").unwrap();
tx.set_savepoint().unwrap(); // inner scope
tx.set("b", "2").unwrap();
tx.release_savepoint().unwrap(); // inner scope succeeded, so keep "b"
assert_eq!(tx.get("b").unwrap(), Some("2".into()));
// Unwinding the outer scope still undoes the released scope's write
tx.rollback_to_savepoint().unwrap();
assert_eq!(tx.get("a").unwrap(), None);
assert_eq!(tx.get("b").unwrap(), None);
Both methods return Error::NoSavepoint when no savepoint is set, and Error::TxNotWritable on a read-only transaction. Savepoints are anonymous, so releasing more savepoints than were set cannot be detected once the stack is non-empty again: a later rollback will silently unwind further than intended.
Note that a rollback rewinds writes only. Keys read and ranges scanned inside a rolled back scope stay tracked for conflict detection, because a write that survives the rollback may have been derived from a value that scope read. This keeps serializable transactions conservative: it can produce a retryable conflict error, never a missed one.
SurrealMX provides powerful range-based operations for scanning, counting, and iterating over keys. All range operations support:
scan_with, keys_for_each) inspecting borrowed slices directly without vector allocations or value clonesscan_into, keys_into) avoiding repeated vector reallocations across query loopsuse surrealmx::Database;
fn main() {
let db = Database::new();
// Insert test data
let mut tx = db.transaction(true);
for i in 1..=10 {
tx.put(&format!("key:{:02}", i), &format!("value:{}", i)).unwrap();
}
tx.commit().unwrap();
let mut tx = db.transaction(false);
// Get all keys in range
let keys = tx.keys("key:03".."key:08", None, None).unwrap();
assert_eq!(keys.len(), 5);
assert_eq!(keys[0].as_ref(), b"key:03");
assert_eq!(keys[4].as_ref(), b"key:07");
// Get key-value pairs in range
let pairs = tx.scan("key:03".."key:06", None, None).unwrap();
assert_eq!(pairs.len(), 3);
assert_eq!(pairs[0].0.as_ref(), b"key:03");
assert_eq!(pairs[0].1.as_ref(), b"value:3");
// Count keys in range
let count = tx.total("key:00".."key:99", None, None).unwrap();
assert_eq!(count, 10);
tx.cancel().unwrap();
}
use surrealmx::Database;
fn main() {
let db = Database::new();
// Insert test data
let mut tx = db.transaction(true);
for i in 1..=100 {
tx.put(format!("item:{:03}", i), format!("value_{}", i)).unwrap();
}
tx.commit().unwrap();
let mut tx = db.transaction(false);
// Paginated forward scan: skip 10, take 5
let page1 = tx.scan("item:000".."item:999", Some(10), Some(5)).unwrap();
assert_eq!(page1.len(), 5);
assert_eq!(page1[0].0.as_ref(), b"item:011");
assert_eq!(page1[4].0.as_ref(), b"item:015");
// Reverse iteration: get last 3 items
let last_items = tx.scan_reverse("item:000".."item:999", None, Some(3)).unwrap();
assert_eq!(last_items.len(), 3);
assert_eq!(last_items[0].0.as_ref(), b"item:100"); // First item is the highest key
assert_eq!(last_items[2].0.as_ref(), b"item:098"); // Last item is lower
tx.cancel().unwrap();
}
use surrealmx::Database;
fn main() {
let db = Database::new();
for i in 0..100 {
db.set(format!("doc:{i:04}"), format!("val_{i}")).unwrap();
}
// Inspect borrowed key (&ByteSlice) and value (&[u8]) directly with zero allocations
db.scan_with("doc:0000".."doc:9999", None, Some(10), |key, val| {
println!("{key:?} => {:?}", std::str::from_utf8(val).unwrap());
true // Return false to stop iteration early
}).unwrap();
// Scan keys with zero allocation
db.keys_for_each("doc:0000".."doc:9999", None, Some(10), |key| {
println!("Key: {key:?}");
true
}).unwrap();
// Reuse a pre-allocated vector buffer across repeated scans
let mut buffer = Vec::new();
db.scan_into("doc:0000".."doc:0050", None, None, &mut buffer).unwrap();
assert_eq!(buffer.len(), 50);
}
Available range operation methods:
scan_with(range, skip, limit, closure): Zero-allocation closure inspection over borrowed (&ByteSlice, &[u8])keys_for_each(range, skip, limit, closure): Zero-allocation closure iteration over borrowed &ByteSlice keysscan_into(range, skip, limit, &mut buffer): Reuses pre-allocated buffer for key-value pairskeys_into(range, skip, limit, &mut buffer): Reuses pre-allocated buffer for keyskeys(range, skip, limit) / keys_reverse(...): Get keys in range as Vec<ByteSlice>scan(range, skip, limit) / scan_reverse(...): Get key-value pairs in range as Vec<(ByteSlice, ByteSlice)>total(range, skip, limit): Count keys in rangeLicensed under the Apache License, Version 2.0. See LICENSE for details.
622 followers · starred Jul 2026
12 followers · starred Nov 2025