ACID Transactions
BEGIN / COMMIT / ROLLBACK blocks with clone-based rollback, single-writer isolation, and WAL-backed durability.Atomicity
All mutations in a transaction succeed together or are fully rolled back. The in-memory state is cloned before execution and restored on failure.
Consistency
Triggers, sync rules, schema registries, and role-based access control enforce invariants before and after every mutation.
Isolation
A single-writer RwLock model serializes all write operations. Concurrent readers never see partial writes.
Durability
Metadata mutations are logged to the WAL with fsync. Graph mutations persist via snapshots. Periodic checkpoints and graceful shutdown guarantee recovery.
Atomicity
Anvil DB uses clone-based rollback to guarantee atomicity. When a BEGIN block is detected, the entire in-memory state is cloned before any inner statement executes. If any statement fails, the original state is restored in full.
Transaction Block Detection
The Cypher dispatcher identifies transaction blocks by checking the first and last statements:
let is_tx_block = first_upper.starts_with("BEGIN TRANSACTION")
|| first_upper == "BEGIN";
let tx_commit = last_upper == "COMMIT"
|| last_upper.starts_with("COMMIT");
let tx_rollback = last_upper == "ROLLBACK"
|| last_upper.starts_with("ROLLBACK");Clone-Based Rollback
Before executing any inner statement, all mutable state is cloned:
// Snapshot state for rollback on failure.
let graph_backup = state.graph.read().await.clone();
let label_backup = state.label_registry.read().await.clone();
let rel_type_backup = state.rel_type_registry.read().await.clone();
let prop_key_backup = state.prop_key_registry.read().await.clone();
let doc_store_backup = state.doc_store.read().await.clone();
let coll_reg_backup = state.collection_registry.read().await.clone();If any statement returns an error, all state is restored from the backups:
if last_result.is_err() {
// ROLLBACK: restore all state from backups.
*state.graph.write().await = graph_backup;
*state.label_registry.write().await = label_backup;
*state.rel_type_registry.write().await = rel_type_backup;
*state.prop_key_registry.write().await = prop_key_backup;
*state.doc_store.write().await = doc_store_backup;
*state.collection_registry.write().await = coll_reg_backup;
return last_result;
}Batch WAL Writes
Multiple mutations within a transaction share a single transaction ID in the WAL. The commit record is written only after all mutations are appended, making the WAL entry atomic:
/// Log multiple mutations as a single transaction. All share one tx_id.
pub fn log_mutations(&mut self, mutations: &[WalMutation]) -> io::Result<Lsn> {
let tx_id = self.next_tx_id.fetch_add(1, Ordering::Relaxed);
for mutation in mutations {
let payload = mutation.to_bytes();
let lsn = self.writer.next_lsn();
let record = WalRecord::insert(lsn, tx_id, payload);
self.writer.append(&record)?;
}
let commit_lsn = self.writer.next_lsn();
self.writer.commit(commit_lsn, tx_id)?; // fsync here
Ok(commit_lsn)
}Transaction Suppression Flag
Inside a transaction block, the in_transaction flag suppresses per-statement snapshot saves, deferring persistence until the final COMMIT:
pub struct CypherRequest {
pub query: String,
pub params: Option<serde_json::Value>,
pub database: Option<String>,
/// Suppresses per-statement snapshot saves inside transaction blocks.
pub in_transaction: bool,
}Example: Atomic Transfer
BEGIN TRANSACTION;
MATCH (src:Person {name: "Alice"})
SET src.balance = src.balance - 500.00
MATCH (dst:Person {name: "Bob"})
SET dst.balance = dst.balance + 500.00
CREATE (src)-[:TRANSFERRED {amount: 500.00, ts: datetime()}]->(dst)
COMMIT;If the CREATE fails, both SET operations are rolled back — Alice and Bob's balances remain unchanged.
Consistency
Anvil DB enforces consistency through four mechanisms: triggers that fire before and after mutations, sync rules that keep graph and document state aligned, schema registries that guarantee stable naming, and role-based access control that prevents unauthorized writes.
Triggers
Stored triggers execute Cypher in response to graph mutations. BEFORE triggers can abort an operation, enforcing preconditions. AFTER triggers execute side effects like syncing documents or creating audit relationships.
| Aspect | Detail |
|---|---|
| Timing | BEFORE (can abort) or AFTER (side effects) |
| Events | INSERT, UPDATE, DELETE, INSERT_OR_UPDATE |
| Targets | Label or Collection |
| Recursion limit | 16 levels (MAX_TRIGGER_DEPTH) |
| Priority | Numeric ordering when multiple triggers fire |
| Variables | OLD, NEW, current_user, is_sync |
CREATE TRIGGER audit_transfers
AFTER INSERT ON TRANSFERRED
FOR EACH ROW
BEGIN
CREATE DOCUMENT IN audit_log {
type: "transfer",
from: NEW.start_node,
to: NEW.end_node,
amount: NEW.amount,
ts: datetime()
}
END;Sync Rules
Sync rules maintain bidirectional consistency between graph nodes and document collections. When a node is created or updated, matching sync rules automatically create or update the corresponding document, and vice versa.
| Property | Options |
|---|---|
| Direction | GraphToDocument, DocumentToGraph, Bidirectional |
| Conflict resolution | LastWriteWins, GraphWins, DocumentWins |
| Loop prevention | syncing flag blocks re-entrant sync operations |
| Trigger control | skip_triggers flag prevents infinite trigger-sync loops |
Schema Registries
Labels, relationship types, and property keys are managed by interned string registries. Each unique name receives a stable numeric ID via get_or_insert(), which is idempotent — the same string always returns the same ID. These registries are included in snapshots, WAL mutations, and transaction rollbacks.
pub struct StringRegistry<Id> {
to_id: HashMap<String, Id>,
to_name: Vec<String>, // index = ID, value = name
}
impl<Id> StringRegistry<Id> {
/// Idempotent: same string always returns same ID.
pub fn get_or_insert(&mut self, name: &str) -> Id {
if let Some(&id) = self.to_id.get(name) {
return id;
}
let id = Id::from(self.to_name.len() as u64);
self.to_id.insert(name.to_string(), id);
self.to_name.push(name.to_string());
id
}
}Role-Based Access Control
Every mutating Cypher statement is checked against the caller's role before execution. Only users with admin or editor role can perform writes. Read-only users receive a permission error before any state change occurs.
let is_mutating = upper.starts_with("CREATE")
|| upper.starts_with("MERGE")
|| upper.starts_with("DELETE")
|| upper.starts_with("SET ")
|| /* ... UPSERT, DROP, BEGIN, SYNC, ALTER, ENABLE, DISABLE, FORCE, MATCH+SET/DELETE ... */;
if is_mutating
&& !session.roles.contains(&"admin".to_string())
&& !session.roles.contains(&"editor".to_string())
{
return Err("Write permission denied. Requires admin or editor role.");
}Row-Level Security
RLS policies provide fine-grained access control at the node and relationship level. Policies use USING predicates for read visibility and WITH CHECK predicates for write validation. Permissive policies are OR'd, restrictive policies are AND'd, and relationship visibility cascades from endpoint node visibility.
Isolation
Anvil DB uses a single-writer model with Tokio async RwLock on each state component. This provides serializable isolation: only one write operation can execute at a time, while concurrent reads are unrestricted.
State Architecture
pub struct AppState {
pub graph: RwLock<MemGraph>,
pub label_registry: RwLock<LabelRegistry>,
pub rel_type_registry: RwLock<RelationshipTypeRegistry>,
pub prop_key_registry: RwLock<PropertyKeyRegistry>,
pub doc_store: RwLock<MemDocumentStore>,
pub collection_registry: RwLock<CollectionRegistry>,
pub sync_engine: RwLock<SyncEngine>,
pub wal: Arc<tokio::sync::Mutex<WalManager>>,
// ...
}Single-Writer Serialization
Write operations acquire exclusive locks on all affected state. The RwLock guarantees that only one async task can hold a write lock at a time — all other writers queue and await their turn. Readers can proceed concurrently and never observe partial writes.
// All write locks acquired together — no other writer can interleave.
let mut graph = state.graph.write().await;
let mut label_reg = state.label_registry.write().await;
let mut rel_type_reg = state.rel_type_registry.write().await;
let mut prop_reg = state.prop_key_registry.write().await;
let mut doc_store = state.doc_store.write().await;
let mut coll_reg = state.collection_registry.write().await;Transaction Isolation
Within a BEGIN...COMMIT block, the snapshot is cloned at the start of the transaction. Inner statements execute against the live state with write locks. If the transaction fails, the pre-transaction clone is swapped back in, ensuring other queries never observe the failed intermediate state.
| Property | Guarantee |
|---|---|
| No dirty reads | Write locks held during mutation — readers see pre- or post-write state, never mid-write |
| No dirty writes | Single-writer RwLock prevents concurrent mutations |
| Rollback safety | Full state cloned before transaction — rollback restores exact pre-transaction state |
| Serializable | All write operations execute serially through the lock |
Durability
Anvil DB achieves durability through a combination of Write-Ahead Logging with fsync, periodic checkpoints, and snapshot persistence. The WAL ensures committed mutations survive process crashes and power failures.
WAL with fsync
Every WAL commit record is followed by an fsync call that flushes the segment file to disk before the write function returns. This guarantees that acknowledged commits are durable:
// WalWriter::commit (writer.rs)
pub fn commit(&mut self, lsn: Lsn, tx_id: u64) -> io::Result<()> {
let record = WalRecord::commit(lsn, tx_id);
self.append(&record)?;
self.current_segment.sync() // delegates to segment
}
// WalSegment::sync (segment.rs)
pub fn sync(&self) -> io::Result<()> {
self.file.sync_all() // OS-level fsync
}Persistence Model
Anvil DB uses a dual persistence strategy. Metadata operations (triggers, functions, sync rules) are logged to the WAL asynchronously via background tasks. Graph mutations (CREATE, SET, DELETE) persist via full snapshot saves. Both paths are backed by periodic checkpoints and graceful shutdown.
| Operation | Persistence | Mechanism |
|---|---|---|
| CREATE/DELETE/SET nodes | Snapshot | save_snapshot() after mutation |
| CREATE/DROP triggers | WAL | log_wal() with fsync on commit |
| CREATE/DROP functions | WAL | log_wal() with fsync on commit |
| Sync rules | WAL | log_wal() with fsync on commit |
| Transaction blocks | Snapshot | save_snapshot() on COMMIT |
Checkpoint Cycle
A background task runs on a configurable interval (default 300s). When the operations threshold is met (default 1,000 ops), the checkpoint saves a full snapshot to disk, writes a checkpoint record to the WAL, and purges old WAL segments:
1. Check ops_since_checkpoint >= threshold (1,000)
2. Save in-memory snapshot to disk (graph.dfgs)
3. Write Checkpoint record to WAL with fsync
4. Purge old WAL segments before current
5. Reset operation counter to 0Graceful Shutdown
On Ctrl+C or process signal, the server saves a final snapshot, writes a checkpoint record, and purges old segments before exiting. The next startup has zero or minimal recovery work.
// Save final snapshot.
if let Err(e) = shutdown_state.save_snapshot().await {
eprintln!("Error saving snapshot: {e}");
}
// Write checkpoint record + purge WAL.
let mut wal = shutdown_state.wal.lock().await;
if let Err(e) = wal.checkpoint() {
eprintln!("Error checkpointing WAL: {e}");
}Crash Recovery
On startup, the server loads the last snapshot from graph.dfgs, then opens the WAL and replays any committed mutations written after the last checkpoint. Incomplete transactions (no commit record) are discarded. See the WAL documentation for full recovery algorithm details.
if !recovery.mutations.is_empty() {
println!(
"WAL recovery: replaying {} mutation(s) from {} committed tx(s)",
recovery.mutations.len(),
recovery.committed_tx_count,
);
wal_replay::replay_wal_mutations(&mut snap, &recovery.mutations)?;
}Test Coverage
ACID properties are verified by tests across multiple crates and the integration test suite.
Transaction Tests
| Test | Verifies |
|---|---|
| tx_block_detected_in_statements | BEGIN/COMMIT parsing from multi-statement input |
| tx_block_rollback_detected | BEGIN/ROLLBACK parsing and detection |
| tx_block_multiline_real_query | Real MATCH+CREATE inside BEGIN...COMMIT block |
WAL Atomicity Tests
| Test | Verifies |
|---|---|
| log_mutations_batch | Multiple mutations share one tx_id, counted as 1 op |
| recovery_replays_committed | Only committed transactions survive recovery |
| recovery_after_checkpoint_only_replays_new | Pre-checkpoint mutations not replayed |
| checkpoint_resets_counter | Checkpoint resets ops counter and purges segments |
RBAC Tests
| Test | Verifies |
|---|---|
| reader_cannot_create | Reader cannot CREATE nodes — write denied |
| reader_cannot_delete | Reader cannot DELETE — write denied |
| reader_cannot_set | Reader cannot SET properties — write denied |
| reader_can_read | Reader can MATCH — query succeeds |
| editor_can_create | Editor can CREATE nodes |
| admin_can_create_document | Admin can create documents |
| reader_cannot_create_document | Reader cannot create documents |
| reader_cannot_upsert_document | Reader cannot upsert documents |
| reader_cannot_create_trigger | Reader cannot create triggers |
| reader_cannot_create_policy | Reader cannot create RLS policies |
Integration Tests (test-cypher.sh)
The shell integration test suite includes transaction-specific tests that verify atomicity across the HTTP API:
# Transaction that should SUCCEED
query "BEGIN TRANSACTION;
MATCH (src:Person {name: \"Alice\"})
MATCH (dst:Person {name: \"Bob\"})
CREATE (src)-[r:TRANSFERRED {amount: 500}]->(dst)
COMMIT;"
# RBAC: reader can read but cannot write
assert_ok "Reader: MATCH (read)" "$READER_TOKEN" \
"MATCH (n:Person) RETURN n LIMIT 1"
assert_error "Reader: CREATE blocked" "$READER_TOKEN" \
"CREATE (:RoleTest {x: 1})" "permission denied"Configuration
# ACID-related settings
[storage]
wal_sync_mode = "fsync" # fsync, fdatasync, or none
checkpoint_interval_secs = 300 # 5 minutes
checkpoint_ops_threshold = 1000 # checkpoint after 1000 operations| Environment Variable | Default | Description |
|---|---|---|
| ANVIL_WAL_SYNC_MODE | fsync | Disk sync mode: fsync, fdatasync, none |
| ANVIL_CHECKPOINT_INTERVAL | 300 | Seconds between checkpoint checks |
| ANVIL_CHECKPOINT_OPS_THRESHOLD | 1000 | Operations before checkpoint triggers |
Implementation Summary
| Property | Mechanism | Key Code |
|---|---|---|
| Atomicity | Clone-based rollback + batch WAL tx_id | handlers.rs: cypher_query() |
| Consistency | Triggers, sync rules, registries, RBAC, RLS | trigger_engine.rs, sync_engine.rs |
| Isolation | Single-writer RwLock (serializable) | app.rs: AppState |
| Durability | WAL + fsync + checkpoints + snapshots | wal_manager.rs, writer.rs |