Transactions in Grafeo¶
Grafeo provides ACID transactions with Snapshot Isolation semantics. This guide explains how transactions work, their guarantees and important limitations to be aware of.
Quick Start¶
from grafeo import GrafeoDB
db = GrafeoDB()
# Explicit transaction
with db.begin_transaction() as tx:
tx.execute("CREATE (n:Person {name: 'Alix'})")
tx.execute("CREATE (n:Person {name: 'Gus'})")
tx.commit() # All changes visible atomically
# Auto-commit mode (default)
db.execute("CREATE (n:Person {name: 'Vincent'})") # Commits immediately
Isolation Level: Snapshot Isolation¶
Grafeo implements Snapshot Isolation (SI), a widely-used isolation level that provides strong consistency while maintaining high concurrency.
Guarantees¶
| Guarantee | Description |
|---|---|
| Repeatable Reads | Reading the same data twice in a transaction returns the same result |
| No Dirty Reads | Uncommitted changes from other transactions are never visible |
| No Lost Updates | Write-write conflicts are detected and one transaction is aborted |
| Consistent Snapshot | All reads see the database as of transaction start time |
How It Works¶
- When a transaction starts, it receives a start epoch representing the current database state
- All reads within the transaction see data as of that epoch
- Writes are buffered and only become visible after commit
- At commit time, the system checks for write-write conflicts
- If another committed transaction wrote to the same entity, the commit fails
Write-Write Conflict Detection¶
Grafeo automatically detects when two transactions try to modify the same entity:
# Thread 1
tx1 = db.begin_transaction()
tx1.execute("MATCH (n:Counter {id: 1}) SET n.value = n.value + 10")
# Thread 2 (concurrent)
tx2 = db.begin_transaction()
tx2.execute("MATCH (n:Counter {id: 1}) SET n.value = n.value + 20")
tx1.commit() # Succeeds
tx2.commit() # Fails with WriteConflict error
When a conflict is detected, the application should: 1. Catch the exception 2. Optionally retry the transaction 3. Or report the conflict
Important Limitation: Write Skew¶
Snapshot Isolation does not prevent all anomalies. The write skew anomaly can occur when transactions read overlapping data but write to different entities.
Example: The Classic Write Skew¶
Consider a constraint where A + B >= 0:
# Initial: A = 50, B = 50
# Transaction 1
tx1 = db.begin_transaction()
a = tx1.execute("MATCH (n:Account {name: 'A'}) RETURN n.balance").scalar() # 50
b = tx1.execute("MATCH (n:Account {name: 'B'}) RETURN n.balance").scalar() # 50
# Check: 50 + 50 - 100 = 0 >= 0, OK
tx1.execute("MATCH (n:Account {name: 'A'}) SET n.balance = -50")
# Transaction 2 (concurrent, sees same snapshot)
tx2 = db.begin_transaction()
a = tx2.execute("MATCH (n:Account {name: 'A'}) RETURN n.balance").scalar() # 50
b = tx2.execute("MATCH (n:Account {name: 'B'}) RETURN n.balance").scalar() # 50
# Check: 50 + 50 - 100 = 0 >= 0, OK
tx2.execute("MATCH (n:Account {name: 'B'}) SET n.balance = -50")
tx1.commit() # Success (wrote to A)
tx2.commit() # Success (wrote to B, no conflict with A)
# Result: A = -50, B = -50, constraint violated!
Workarounds for Write Skew¶
Option 1: Promote Reads to Writes
Add a dummy write to read entities to force conflict detection:
tx = db.begin_transaction()
# Read both accounts
a = tx.execute("MATCH (n:Account {name: 'A'}) RETURN n").scalar()
b = tx.execute("MATCH (n:Account {name: 'B'}) RETURN n").scalar()
# "Touch" both accounts to register them in write set
tx.execute("MATCH (n:Account {name: 'A'}) SET n._touched = timestamp()")
tx.execute("MATCH (n:Account {name: 'B'}) SET n._touched = timestamp()")
# Now make actual change
tx.execute("MATCH (n:Account {name: 'A'}) SET n.balance = -50")
tx.commit() # Will conflict if another tx touched A or B
Option 2: Application-Level Validation
Re-check constraints before commit:
def withdraw(db, account, amount):
while True:
tx = db.begin_transaction()
try:
# Read current state
a = tx.execute("MATCH (n:Account {name: 'A'}) RETURN n.balance").scalar()
b = tx.execute("MATCH (n:Account {name: 'B'}) RETURN n.balance").scalar()
# Make change
if account == 'A':
new_a = a - amount
if new_a + b < 0:
raise ValueError("Would violate constraint")
tx.execute(f"MATCH (n:Account {{name: 'A'}}) SET n.balance = {new_a}")
tx.commit()
return # Success
except WriteConflictError:
continue # Retry
Option 3: External Locking
Use database-external locks for critical operations:
import threading
account_lock = threading.Lock()
def withdraw(db, account, amount):
with account_lock: # Serializes all withdrawals
tx = db.begin_transaction()
# ... perform withdrawal ...
tx.commit()
What Gets Rolled Back¶
When a transaction is rolled back (either fully or to a savepoint), all mutations made within the rollback scope are undone:
| Mutation | Rolled Back? |
|---|---|
SET n.prop = value (property update) | Yes |
SET n.prop = value (new property) | Yes, property removed |
REMOVE n.prop | Yes, property restored |
SET n:Label (add label) | Yes, label removed |
REMOVE n:Label | Yes, label restored |
MERGE ... ON MATCH SET | Yes, properties restored |
INSERT (new node/edge) | Yes, entity removed |
DELETE (remove node/edge) | Yes, entity restored |
Savepoints¶
Savepoints let you create named checkpoints within a transaction. Rolling back to a savepoint undoes only the changes made after it while preserving earlier work.
Usage¶
tx = db.begin_transaction()
tx.execute("MATCH (a:Account {id: 'A001'}) SET a.balance = 1000")
tx.savepoint("before_bonus")
tx.execute("MATCH (a:Account {id: 'A001'}) SET a.bonus = 500")
# Undo only the bonus, keep the balance change
tx.rollback_to_savepoint("before_bonus")
# Release discards the savepoint but keeps changes
# tx.release_savepoint("before_bonus")
tx.commit() # balance = 1000, no bonus property
Nested Transactions¶
Starting a transaction inside an existing one creates an implicit savepoint. Rolling back the inner transaction undoes only its changes:
tx = db.begin_transaction()
tx.execute("MATCH (n:Counter) SET n.value = 1")
# Inner transaction (implicit savepoint)
tx2 = db.begin_transaction()
tx2.execute("MATCH (n:Counter) SET n.value = 99")
tx2.rollback() # Undoes SET n.value = 99
# n.value is still 1
tx.commit()
Savepoint Rules¶
- Names must be unique within a transaction
- Rolling back to a savepoint also releases all savepoints created after it
- A full
ROLLBACKundoes everything, including changes before any savepoints
Transaction Lifecycle¶
States¶
| State | Description |
|---|---|
Active | Transaction is in progress, can read and write |
Committed | Transaction completed successfully, changes visible |
Aborted | Transaction was rolled back, changes discarded |
Best Practices¶
- Keep transactions short: Long transactions increase conflict probability
- Batch related changes: Group related writes in a single transaction
- Handle conflicts gracefully: Implement retry logic for write conflicts
- Use auto-commit for single operations: Simpler and equally safe
- Don't hold transactions open during user interaction: Risk of blocking GC
Session Drop Safety¶
If a session is dropped (goes out of scope) while a transaction is active, the transaction is automatically rolled back. This prevents data corruption from forgotten commits:
def do_work(db):
tx = db.begin_transaction()
tx.execute("CREATE (n:Temp {data: 'test'})")
# Oops, forgot to commit!
# When tx goes out of scope, the transaction is rolled back automatically
do_work(db)
# No Temp nodes exist, the uncommitted data was discarded
In Rust, the same behavior applies when a Session is dropped:
{
let session = db.session();
session.begin_transaction()?;
session.execute("INSERT (:Temp {data: 'test'})")?;
// session dropped here, transaction auto-rolled back
}
API Reference¶
Python¶
# Start explicit transaction
tx = db.begin_transaction()
# Execute within transaction
result = tx.execute("MATCH (n) RETURN n")
# Commit changes
tx.commit()
# Or rollback
tx.rollback()
# Context manager (auto-rollback on exception)
with db.begin_transaction() as tx:
tx.execute("CREATE (n:Test)")
tx.commit()
# Savepoints
tx.savepoint("sp1")
tx.rollback_to_savepoint("sp1")
tx.release_savepoint("sp1")
Rust¶
// Start transaction
let tx_id = session.begin_transaction()?;
// Execute queries
let result = session.execute("MATCH (n) RETURN n")?;
// Commit
session.commit()?;
// Or rollback
session.rollback()?;
// Savepoints
session.savepoint("sp1")?;
session.rollback_to_savepoint("sp1")?;
session.release_savepoint("sp1")?;
Garbage Collection¶
Grafeo automatically garbage collects old transaction metadata and version chains:
- Aborted transactions are cleaned up immediately
- Committed transaction metadata is retained until no active transaction can see it
- Version chains are pruned based on the oldest active transaction's start epoch
This happens automatically; no manual intervention is needed.
Future: Serializable Isolation¶
Full Serializable isolation (preventing write skew) is planned for a future release. This will include:
- Read-write conflict detection
- Serializable Snapshot Isolation (SSI) implementation
- Configurable isolation levels per transaction
For now, use the workarounds described above if the application requires serializable semantics.