WitDatabase provides enterprise-grade transaction support with full ACID compliance, multiple isolation levels, and advanced concurrency control through MVCC and row-level locking. This guide covers everything you need to know about transactions, from basic usage to advanced scenarios.


1. Overview

What is a Transaction?

A transaction is a unit of work that groups multiple database operations into a single, atomic operation. Either all operations in the transaction succeed and are permanently saved (commit), or none of them are (rollback).

Consider transferring money between bank accounts:

[[Svg Src="./witdatabase-transaction-comparison.svg" Alt="witdatabase-transaction-comparison"]]

Transactions solve the fundamental problem of partial failures in multi-step operations.

Why Transactions Matter

Transactions provide four critical guarantees, known as ACID properties:

Property Guarantee Real-World Analogy
Atomicity All or nothing Writing a check — it either clears completely or bounces
Consistency Rules always enforced A balanced ledger — debits always equal credits
Isolation No interference Private workspace — others don't see your draft
Durability Permanent once committed Ink on paper — can't be erased

Transaction Lifecycle

Every transaction follows this lifecycle:

[[Svg Src="./witdatabase-transaction-lifecycle.svg" Alt="witdatabase-transaction-lifecycle"]]


2. ACID Properties

ACID is the foundation of reliable database systems. Understanding these properties helps you design robust applications.

Atomicity

"All or nothing" — Either every operation in a transaction succeeds, or none do. There is no partial completion.

Atomicity is crucial when multiple operations must succeed together:

using var transaction = connection.BeginTransaction();

try
{
    using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;
    
    // Operation 1: Deduct from source account
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @from";
    cmd.Parameters.AddWithValue("@amount", 100.00m);
    cmd.Parameters.AddWithValue("@from", sourceAccountId);
    int rowsAffected = cmd.ExecuteNonQuery();
    
    if (rowsAffected == 0)
        throw new InvalidOperationException("Source account not found");
    
    // Operation 2: Add to destination account
    cmd.Parameters.Clear();
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance + @amount WHERE Id = @to";
    cmd.Parameters.AddWithValue("@amount", 100.00m);
    cmd.Parameters.AddWithValue("@to", destAccountId);
    rowsAffected = cmd.ExecuteNonQuery();
    
    if (rowsAffected == 0)
        throw new InvalidOperationException("Destination account not found");
    
    // Both succeeded — make permanent
    transaction.Commit();
}
catch
{
    // Something failed — undo everything
    transaction.Rollback();
    throw;
}

If the system crashes between Operation 1 and Operation 2, WitDatabase automatically rolls back the incomplete transaction when it restarts. No money is lost.

How WitDatabase implements atomicity:

  • All changes are first written to the Write-Ahead Log (WAL)
  • Changes are applied to the database only after WAL is safely on disk
  • On crash recovery, incomplete transactions are rolled back using the WAL

Consistency

"Rules are always enforced" — The database moves from one valid state to another. Constraints (PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK) are never violated.

Consistency ensures your data always makes sense:

// This transaction maintains consistency
using var transaction = connection.BeginTransaction();

try
{
    using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;
    
    // Try to insert an order for a non-existent customer
    cmd.CommandText = @"
        INSERT INTO Orders (CustomerId, Total, OrderDate) 
        VALUES (@customerId, @total, @date)";
    cmd.Parameters.AddWithValue("@customerId", 99999); // Doesn't exist!
    cmd.Parameters.AddWithValue("@total", 150.00m);
    cmd.Parameters.AddWithValue("@date", DateTime.Now);
    
    cmd.ExecuteNonQuery(); // Throws: Foreign key constraint violation
    
    transaction.Commit();
}
catch (WitDbConstraintException ex)
{
    transaction.Rollback();
    // Database remains consistent — no orphan orders
    Console.WriteLine(
Loading...
quot;Cannot create order: {ex.Message}"); }

Consistency guarantees in WitDatabase:

  • PRIMARY KEY: Unique identifier for each row
  • UNIQUE: No duplicate values in a column
  • NOT NULL: Required values
  • FOREIGN KEY: Referential integrity between tables
  • CHECK: Custom validation rules

Isolation

"Transactions don't interfere" — Concurrent transactions execute as if they were running alone. One transaction doesn't see another's uncommitted changes (depending on isolation level).

Consider two users updating the same data simultaneously:

[[Svg Src="./witdatabase-transaction-isolation.svg" Alt="witdatabase-transaction-isolation"]]

WitDatabase provides five isolation levels to control this behavior. Higher isolation means more protection but potentially lower concurrency.

Durability

"Committed = Permanent" — Once a transaction commits successfully, the changes survive any subsequent failures, including power outages, crashes, or hardware failures.

[[Svg Src="./witdatabase-wal-durability-timeline.svg" Alt="witdatabase-wal-durability-timeline"]]

How WitDatabase ensures durability:

  • Write-Ahead Logging (WAL): Changes logged before applied
  • Synchronous commits: WAL flushed to disk before commit returns
  • Crash recovery: Uncommitted changes rolled back, committed changes preserved

You can trade durability for performance in specific scenarios:

// Maximum durability (default)
"Data Source=app.witdb;Journal=wal"

// Faster but less durable (data loss possible on crash)
"Data Source=app.witdb;Sync Writes=false"

ACID in Practice

Here's a complete example demonstrating all four ACID properties:

public async Task<bool> TransferMoneyAsync(int fromAccount, int toAccount, decimal amount)
{
    await using var connection = new WitDbConnection(_connectionString);
    await connection.OpenAsync();
    
    // ATOMICITY: All operations in one transaction
    await using var transaction = await connection.BeginTransactionAsync();
    
    try
    {
        await using var cmd = connection.CreateCommand();
        cmd.Transaction = transaction;
        
        // CONSISTENCY: Check business rules
        cmd.CommandText = "SELECT Balance FROM Accounts WHERE Id = @id";
        cmd.Parameters.AddWithValue("@id", fromAccount);
        var balance = (decimal?)await cmd.ExecuteScalarAsync();
        
        if (balance == null)
            throw new InvalidOperationException("Source account not found");
        
        if (balance < amount)
            throw new InvalidOperationException("Insufficient funds");
        
        // ISOLATION: Other transactions don't see these changes until commit
        cmd.Parameters.Clear();
        cmd.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @id";
        cmd.Parameters.AddWithValue("@amount", amount);
        cmd.Parameters.AddWithValue("@id", fromAccount);
        await cmd.ExecuteNonQueryAsync();
        
        cmd.Parameters.Clear();
        cmd.CommandText = "UPDATE Accounts SET Balance = Balance + @amount WHERE Id = @id";
        cmd.Parameters.AddWithValue("@amount", amount);
        cmd.Parameters.AddWithValue("@id", toAccount);
        await cmd.ExecuteNonQueryAsync();
        
        // DURABILITY: After commit returns, changes survive crashes
        await transaction.CommitAsync();
        return true;
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

3. Transaction Basics

This section covers the fundamental patterns for working with transactions in WitDatabase.

Starting a Transaction

Transactions are created from an open connection:

using var connection = new WitDbConnection("Data Source=app.witdb");
connection.Open();

// Begin transaction with default isolation level (ReadCommitted)
using var transaction = connection.BeginTransaction();

// All commands must be associated with the transaction
using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;  // Required!

cmd.CommandText = "INSERT INTO Logs (Message) VALUES ('Transaction started')";
cmd.ExecuteNonQuery();

transaction.Commit();  // Or transaction.Rollback();

Always associate commands with the transaction via cmd.Transaction = transaction. Commands executed without this association won't be part of the transaction.

The Standard Pattern

Always use try-catch-finally to ensure proper cleanup:

using var connection = new WitDbConnection(connectionString);
connection.Open();

using var transaction = connection.BeginTransaction();

try
{
    // Perform operations
    using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;
    
    cmd.CommandText = "UPDATE Products SET Stock = Stock - 1 WHERE Id = @id";
    cmd.Parameters.AddWithValue("@id", productId);
    cmd.ExecuteNonQuery();
    
    cmd.Parameters.Clear();
    cmd.CommandText = "INSERT INTO OrderItems (OrderId, ProductId) VALUES (@orderId, @productId)";
    cmd.Parameters.AddWithValue("@orderId", orderId);
    cmd.Parameters.AddWithValue("@productId", productId);
    cmd.ExecuteNonQuery();
    
    // Success — commit
    transaction.Commit();
    Console.WriteLine("Order placed successfully");
}
catch (Exception ex)
{
    // Failure — rollback
    transaction.Rollback();
    Console.WriteLine(
Loading...
quot;Order failed: {ex.Message}"); throw; }

If you forget to call Commit() or Rollback(), the transaction is automatically rolled back when disposed.

Async Transactions

For async code (ASP.NET Core, etc.), use the async variants:

await using var connection = new WitDbConnection(connectionString);
await connection.OpenAsync();

await using var transaction = await connection.BeginTransactionAsync();

try
{
    await using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;
    
    cmd.CommandText = "UPDATE Inventory SET Quantity = Quantity - @qty WHERE ProductId = @id";
    cmd.Parameters.AddWithValue("@qty", orderQuantity);
    cmd.Parameters.AddWithValue("@id", productId);
    await cmd.ExecuteNonQueryAsync();
    
    cmd.Parameters.Clear();
    cmd.CommandText = "INSERT INTO Orders (ProductId, Quantity) VALUES (@id, @qty)";
    cmd.Parameters.AddWithValue("@id", productId);
    cmd.Parameters.AddWithValue("@qty", orderQuantity);
    await cmd.ExecuteNonQueryAsync();
    
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

Auto-Commit Mode

When you execute commands without an explicit transaction, each command runs in its own auto-committed transaction:

// Each statement is auto-committed
cmd.CommandText = "INSERT INTO Logs (Message) VALUES ('Log 1')";
cmd.ExecuteNonQuery();  // Committed immediately

cmd.CommandText = "INSERT INTO Logs (Message) VALUES ('Log 2')";
cmd.ExecuteNonQuery();  // Committed immediately

This is convenient for simple operations but doesn't provide atomicity across multiple statements.

SQL Transaction Statements

You can also control transactions via SQL:

// Using SQL statements
engine.Execute("BEGIN TRANSACTION");

engine.Execute("INSERT INTO Orders (CustomerId) VALUES (1)");
engine.Execute("INSERT INTO OrderItems (OrderId, ProductId) VALUES (1, 100)");

engine.Execute("COMMIT");
// Or: engine.Execute("ROLLBACK");

SQL transaction control is useful when:

  • Working with the SQL Engine directly
  • Running migration scripts
  • Executing stored procedures

4. Savepoints

Savepoints create checkpoints within a transaction, allowing partial rollback without aborting the entire transaction.

Why Savepoints?

Sometimes you need to undo part of a transaction while keeping the rest:

[[Svg Src="./witdatabase-savepoints-comparison.svg" Alt="witdatabase-savepoints-comparison"]]

Savepoints are essential for complex business logic where some operations can fail independently.

Creating Savepoints

Use Save() to create a savepoint:

using var transaction = connection.BeginTransaction();
using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;

try
{
    // Create the order (must succeed)
    cmd.CommandText = "INSERT INTO Orders (CustomerId, Total) VALUES (@cid, 0) RETURNING Id";
    cmd.Parameters.AddWithValue("@cid", customerId);
    var orderId = (long)cmd.ExecuteScalar()!;
    
    // Create savepoint before adding items
    transaction.Save("before_items");
    
    decimal total = 0;
    foreach (var item in orderItems)
    {
        try
        {
            cmd.Parameters.Clear();
            cmd.CommandText = @"
                INSERT INTO OrderItems (OrderId, ProductId, Quantity, Price)
                VALUES (@orderId, @productId, @qty, @price)";
            cmd.Parameters.AddWithValue("@orderId", orderId);
            cmd.Parameters.AddWithValue("@productId", item.ProductId);
            cmd.Parameters.AddWithValue("@qty", item.Quantity);
            cmd.Parameters.AddWithValue("@price", item.Price);
            cmd.ExecuteNonQuery();
            
            total += item.Quantity * item.Price;
        }
        catch (WitDbException ex)
        {
            // Individual item failed — log and continue
            Console.WriteLine(
Loading...
quot;Could not add item {item.ProductId}: {ex.Message}"); } } // Update order total cmd.Parameters.Clear(); cmd.CommandText = "UPDATE Orders SET Total = @total WHERE Id = @id"; cmd.Parameters.AddWithValue("@total", total); cmd.Parameters.AddWithValue("@id", orderId); cmd.ExecuteNonQuery(); transaction.Commit(); } catch { transaction.Rollback(); throw; }

Rolling Back to a Savepoint

Use Rollback(savepointName) to undo changes back to a savepoint:

using var transaction = connection.BeginTransaction();
using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;

// Phase 1: Create customer (must succeed)
cmd.CommandText = "INSERT INTO Customers (Name) VALUES (@name) RETURNING Id";
cmd.Parameters.AddWithValue("@name", "New Customer");
var customerId = (long)cmd.ExecuteScalar()!;

// Phase 2: Try to import their data (may fail)
transaction.Save("before_import");

try
{
    ImportCustomerData(cmd, customerId, externalData);
}
catch (Exception ex)
{
    Console.WriteLine(
Loading...
quot;Import failed: {ex.Message}, using defaults"); // Rollback import, keep customer transaction.Rollback("before_import"); // Apply default settings instead ApplyDefaultSettings(cmd, customerId); } transaction.Commit(); // Customer is saved with either imported or default data

Nested Savepoints

You can create multiple savepoints for fine-grained control:

using var transaction = connection.BeginTransaction();

// Level 1
cmd.CommandText = "INSERT INTO A VALUES (1)";
cmd.ExecuteNonQuery();

transaction.Save("sp1");

    // Level 2
    cmd.CommandText = "INSERT INTO B VALUES (2)";
    cmd.ExecuteNonQuery();
    
    transaction.Save("sp2");
    
        // Level 3
        cmd.CommandText = "INSERT INTO C VALUES (3)";
        cmd.ExecuteNonQuery();
        
        // Rollback to sp2 (undoes C, keeps A and B)
        transaction.Rollback("sp2");
    
    // Rollback to sp1 (undoes B, keeps A)
    transaction.Rollback("sp1");

transaction.Commit();  // Only A is committed

Releasing Savepoints

Use Release() to remove a savepoint you no longer need:

transaction.Save("checkpoint");

// ... operations succeeded ...

// Release savepoint (can't rollback to it anymore)
transaction.Release("checkpoint");

// This would throw:
// transaction.Rollback("checkpoint"); // Error: savepoint doesn't exist

Releasing savepoints can free memory in long transactions with many savepoints.

SQL Savepoint Syntax

Via SQL statements:

BEGIN TRANSACTION;

INSERT INTO Orders (CustomerId) VALUES (1);

SAVEPOINT before_items;

INSERT INTO OrderItems (OrderId, ProductId) VALUES (1, 100);
INSERT INTO OrderItems (OrderId, ProductId) VALUES (1, 101);

-- Something went wrong with items
ROLLBACK TO SAVEPOINT before_items;

-- Try different approach
INSERT INTO OrderItems (OrderId, ProductId) VALUES (1, 200);

COMMIT;

Savepoint Best Practices

Do:

  • Use meaningful savepoint names (before_items, pre_validation)
  • Create savepoints before risky operations
  • Handle rollback-to-savepoint as a normal control flow
  • Release savepoints you no longer need in long transactions

Don't:

  • Create too many savepoints (memory overhead)
  • Use savepoints as a substitute for proper error handling
  • Forget that rollback-to-savepoint doesn't end the transaction
  • Assume savepoints work across connections (they don't)

5. Isolation Levels

Isolation levels control how concurrent transactions interact with each other. WitDatabase supports five isolation levels, each providing different trade-offs between consistency and performance.

Understanding Concurrency Anomalies

Before diving into isolation levels, let's understand the problems they solve:

Dirty Read — Reading uncommitted changes that may be rolled back.

[[Svg Src="./witdatabase-dirty-read.svg" Alt="witdatabase-dirty-read"]]

Non-Repeatable Read — Same query returns different values within one transaction.

[[Svg Src="./witdatabase-non-repeatable-read.svg" Alt="witdatabase-non-repeatable-read"]]

Phantom Read — Same query returns different rows within one transaction.

[[Svg Src="./witdatabase-phantom-read.svg" Alt="witdatabase-phantom-read"]]

Isolation Level Comparison

Level Dirty Read Non-Repeatable Read Phantom Read Performance
READ UNCOMMITTED ⚠️ Possible ⚠️ Possible ⚠️ Possible Fastest
READ COMMITTED ✅ Prevented ⚠️ Possible ⚠️ Possible Fast
REPEATABLE READ ✅ Prevented ✅ Prevented ⚠️ Possible Medium
SERIALIZABLE ✅ Prevented ✅ Prevented ✅ Prevented Slowest
SNAPSHOT ✅ Prevented ✅ Prevented ✅ Prevented Fast

Setting Isolation Level

// ADO.NET: specify when beginning transaction
using var transaction = connection.BeginTransaction(IsolationLevel.Snapshot);

// SQL: set before beginning transaction
engine.Execute("SET TRANSACTION ISOLATION LEVEL SNAPSHOT");
engine.Execute("BEGIN TRANSACTION");

// Connection string: set default for all transactions
"Data Source=app.witdb;Isolation Level=Snapshot"

// Builder: set default isolation level
var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithTransactions()
    .WithDefaultIsolationLevel(WitIsolationLevel.Snapshot)
    .Build();

READ UNCOMMITTED

The lowest isolation level. Transactions can see uncommitted changes from other transactions. This provides maximum concurrency but minimum consistency.

Behavior:

  • Can read data that another transaction has modified but not yet committed
  • If that transaction rolls back, you've read data that never existed
  • No read locks acquired

When to use:

  • Approximate counts or statistics where exact accuracy isn't critical
  • Monitoring dashboards showing real-time approximations
  • Situations where performance matters more than consistency
// Example: Dashboard showing approximate user count
using var tx = connection.BeginTransaction(IsolationLevel.ReadUncommitted);
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

cmd.CommandText = "SELECT COUNT(*) FROM Users";
var approximateCount = (long)cmd.ExecuteScalar()!;
// Might be slightly off, but very fast

tx.Commit();

Warning: Almost never use this for business logic. It's only appropriate when you explicitly accept that results may be inconsistent.

READ COMMITTED

The default isolation level. Only committed data is visible. This prevents dirty reads but allows non-repeatable reads and phantoms.

Behavior:

  • Only sees data that has been committed
  • Same query may return different results if another transaction commits between reads
  • Read locks released immediately after reading

When to use:

  • Default choice for most applications
  • Simple CRUD operations
  • When you don't need to re-read data within the same transaction
// Default isolation level
using var tx = connection.BeginTransaction(); // ReadCommitted by default
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

// This read only sees committed data
cmd.CommandText = "SELECT Balance FROM Accounts WHERE Id = 1";
var balance = (decimal)cmd.ExecuteScalar()!;

// If another transaction commits a change here,
// a second read would see the new value

tx.Commit();

REPEATABLE READ

Prevents dirty reads and non-repeatable reads. Once you read a value, it won't change within your transaction (but new rows may appear).

Behavior:

  • Read locks held until transaction ends
  • Same query returns same value throughout transaction
  • New rows inserted by other transactions may still appear (phantoms)

When to use:

  • Reports that query the same data multiple times
  • Calculations that depend on consistent values
  • When you need to read, process, and re-read data
// Example: Calculating average order value
using var tx = connection.BeginTransaction(IsolationLevel.RepeatableRead);
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

// Read total
cmd.CommandText = "SELECT SUM(Amount) FROM Orders WHERE CustomerId = @id";
cmd.Parameters.AddWithValue("@id", customerId);
var total = (decimal)cmd.ExecuteScalar()!;

// Read count
cmd.Parameters.Clear();
cmd.CommandText = "SELECT COUNT(*) FROM Orders WHERE CustomerId = @id";
cmd.Parameters.AddWithValue("@id", customerId);
var count = (long)cmd.ExecuteScalar()!;

// This is consistent: total and count from same snapshot
var average = count > 0 ? total / count : 0;

tx.Commit();

SERIALIZABLE

The highest isolation level. Provides complete isolation — transactions execute as if they were running one at a time, serially.

Behavior:

  • Range locks prevent phantom reads
  • Transactions may block or fail due to conflicts
  • Maximum consistency, minimum concurrency

When to use:

  • Financial transactions where accuracy is critical
  • Inventory management (checking and updating stock)
  • Any scenario where concurrent modifications could cause data corruption
// Example: Checking and withdrawing from account
using var tx = connection.BeginTransaction(IsolationLevel.Serializable);
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

// Check balance
cmd.CommandText = "SELECT Balance FROM Accounts WHERE Id = @id";
cmd.Parameters.AddWithValue("@id", accountId);
var balance = (decimal)cmd.ExecuteScalar()!;

if (balance >= amount)
{
    // Withdraw — no other transaction can change balance between SELECT and UPDATE
    cmd.Parameters.Clear();
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @id";
    cmd.Parameters.AddWithValue("@amount", amount);
    cmd.Parameters.AddWithValue("@id", accountId);
    cmd.ExecuteNonQuery();
    
    tx.Commit();
    return true;
}
else
{
    tx.Rollback();
    return false; // Insufficient funds
}

SNAPSHOT

Uses Multi-Version Concurrency Control (MVCC) to provide a consistent snapshot of data as of the transaction start. This gives the consistency of Serializable with much better concurrency.

Behavior:

  • Each transaction sees database as it was when the transaction started
  • Readers never block writers, writers never block readers
  • Write conflicts are detected at commit time
  • Uses more storage for multiple versions

When to use:

  • Read-heavy workloads with occasional writes
  • Long-running reports alongside OLTP operations
  • When you need consistency without blocking
  • Recommended default for most applications using MVCC
// Example: Generating report while other transactions modify data
using var tx = connection.BeginTransaction(IsolationLevel.Snapshot);
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

// All queries in this transaction see a consistent snapshot
// from the moment the transaction started

cmd.CommandText = "SELECT SUM(Amount) FROM Orders WHERE Date >= @start";
cmd.Parameters.AddWithValue("@start", DateTime.Today.AddMonths(-1));
var monthlyTotal = (decimal?)cmd.ExecuteScalar() ?? 0;

cmd.Parameters.Clear();
cmd.CommandText = "SELECT AVG(Amount) FROM Orders WHERE Date >= @start";
cmd.Parameters.AddWithValue("@start", DateTime.Today.AddMonths(-1));
var monthlyAverage = (decimal?)cmd.ExecuteScalar() ?? 0;

// Even if other transactions are inserting orders right now,
// our totals and averages are consistent with each other

tx.Commit();

Choosing the Right Isolation Level

Scenario Recommended Level Reason
Simple CRUD operations READ COMMITTED Good default, minimal overhead
Dashboard/monitoring READ UNCOMMITTED Speed over accuracy
Multi-read calculations REPEATABLE READ Consistent values
Financial transactions SERIALIZABLE Critical accuracy
Reports with OLTP SNAPSHOT Consistency without blocking
General recommendation SNAPSHOT Best balance

Isolation Level in SQL

-- Set isolation level before beginning transaction
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;

-- Your queries here...

COMMIT;

-- Available levels:
-- SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- SET TRANSACTION ISOLATION LEVEL SNAPSHOT;

6. MVCC (Multi-Version Concurrency Control)

MVCC is a concurrency control mechanism that allows multiple transactions to access the same data simultaneously without blocking each other. It's the foundation for Snapshot isolation and high-concurrency workloads.

How MVCC Works

Instead of modifying data in place, MVCC creates a new version of each row when it's updated:

[[Svg Src="./witdatabase-locking-vs-mvcc.svg" Alt="witdatabase-locking-vs-mvcc"]]

Key concepts:

  • Each write creates a new version, not an in-place update
  • Each version is tagged with the transaction ID that created it
  • Readers see the version that was current when their transaction started
  • Old versions are cleaned up by garbage collection

Benefits of MVCC

Benefit Explanation
Readers don't block writers Readers see old version while writer creates new one
Writers don't block readers Readers continue seeing their snapshot
Consistent snapshots Transaction sees database at a single point in time
High concurrency Multiple transactions can work on same data
No read locks Reduced lock contention

Enabling MVCC

MVCC is enabled by default but can be explicitly configured:

// Via connection string
"Data Source=app.witdb;MVCC=true"

// Via builder
var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithBTree()
    .WithTransactions()
    .WithMvcc()  // Enable MVCC
    .WithDefaultIsolationLevel(WitIsolationLevel.Snapshot)
    .Build();

// Disable MVCC for simple single-writer scenarios
"Data Source=app.witdb;MVCC=false"

MVCC in Action

Let's trace through a concurrent scenario:

[[Svg Src="./witdatabase-mvcc-snapshot-timeline.svg" Alt="witdatabase-mvcc-snapshot-timeline"]]

Transaction A sees a consistent view of the database throughout its execution, even though Transaction B modified and committed changes during that time.

Write Conflicts

MVCC detects conflicts when two transactions try to modify the same row:

[[Svg Src="./witdatabase-mvcc-write-conflict.svg" Alt="witdatabase-mvcc-write-conflict"]]

WitDatabase handles this conflict:

using var txA = connection.BeginTransaction(IsolationLevel.Snapshot);

try
{
    // Try to update
    cmd.Transaction = txA;
    cmd.CommandText = "UPDATE Products SET Price = 250 WHERE Id = 1";
    cmd.ExecuteNonQuery();
    
    txA.Commit(); // May throw if conflict detected
}
catch (WitDbConcurrencyException ex)
{
    // Another transaction modified this row
    // Retry with fresh data
    txA.Rollback();
    
    // Retry logic...
}

Garbage Collection

Old versions accumulate as transactions create new ones. MVCC includes automatic garbage collection:

[[Svg Src="./witdatabase-mvcc-gc.svg" Alt="witdatabase-mvcc-gc"]]

Garbage collection runs automatically in the background. You don't need to manage it manually.

MVCC Trade-offs

Advantage Trade-off
High concurrency More storage for versions
No read blocking Write conflicts require retry logic
Consistent snapshots Old versions must be garbage collected
Great for reads Write-heavy workloads create many versions

When to Use MVCC

Use MVCC when:

  • Read-heavy workloads (reports, analytics, dashboards)
  • Long-running transactions that shouldn't block others
  • Web applications with many concurrent users
  • You need consistent snapshots for complex queries

Consider disabling MVCC when:

  • Single-writer scenarios (batch processing, ETL)
  • Write-heavy workloads with few reads
  • Memory is constrained (versions use memory)
  • You don't need snapshot isolation

MVCC Best Practices

Keep transactions short:

// GOOD: Short transaction
using var tx = connection.BeginTransaction(IsolationLevel.Snapshot);
var data = ReadData(cmd);
tx.Commit();

ProcessData(data); // Processing outside transaction

// BAD: Long transaction holds snapshot, prevents GC
using var tx = connection.BeginTransaction(IsolationLevel.Snapshot);
var data = ReadData(cmd);
ProcessData(data); // Long processing inside transaction
tx.Commit();

Handle write conflicts:

public async Task UpdateWithRetryAsync(int id, decimal newPrice, int maxRetries = 3)
{
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        await using var tx = await connection.BeginTransactionAsync(IsolationLevel.Snapshot);
        
        try
        {
            await using var cmd = connection.CreateCommand();
            cmd.Transaction = tx;
            
            cmd.CommandText = "UPDATE Products SET Price = @price WHERE Id = @id";
            cmd.Parameters.AddWithValue("@price", newPrice);
            cmd.Parameters.AddWithValue("@id", id);
            await cmd.ExecuteNonQueryAsync();
            
            await tx.CommitAsync();
            return; // Success
        }
        catch (WitDbConcurrencyException)
        {
            await tx.RollbackAsync();
            
            if (attempt == maxRetries)
                throw; // Give up after max retries
            
            // Wait before retry (exponential backoff)
            await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt));
        }
    }
}

Use read-only transactions when appropriate:

// For reports that only read data, mark transaction as read-only
// This allows MVCC to optimize and not track write sets
using var tx = connection.BeginTransaction(IsolationLevel.Snapshot);

// Only perform reads
var report = GenerateReport(cmd);

tx.Commit(); // No writes means no conflict possible

7. Row-Level Locking

While MVCC handles most concurrency scenarios elegantly, sometimes you need explicit locks to coordinate access to specific rows. WitDatabase provides SQL-standard row-level locking with FOR UPDATE and FOR SHARE clauses.

Lock Types

WitDatabase supports two types of row locks:

Lock Type SQL Syntax Purpose Compatible With
Exclusive FOR UPDATE Modify the row Nothing
Shared FOR SHARE Prevent others from modifying Other shared locks

Lock compatibility matrix:

Requested \ Held No Lock FOR SHARE FOR UPDATE
FOR SHARE ✅ Granted ✅ Granted ❌ Blocked
FOR UPDATE ✅ Granted ❌ Blocked ❌ Blocked

FOR UPDATE (Exclusive Lock)

Use FOR UPDATE when you intend to modify the row. This prevents other transactions from reading (with FOR UPDATE/SHARE) or modifying the row until your transaction completes.

-- Lock the row for update
SELECT * FROM Accounts WHERE Id = 1 FOR UPDATE;

-- Now only this transaction can modify this row
UPDATE Accounts SET Balance = Balance - 100 WHERE Id = 1;

COMMIT;  -- Lock released

Typical use case: Read-then-modify patterns where you need to ensure no one changes the data between your read and write.

using var tx = connection.BeginTransaction(IsolationLevel.ReadCommitted);
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

// Lock the row
cmd.CommandText = "SELECT Balance FROM Accounts WHERE Id = @id FOR UPDATE";
cmd.Parameters.AddWithValue("@id", accountId);
var balance = (decimal)cmd.ExecuteScalar()!;

// Check and update (no race condition possible)
if (balance >= amount)
{
    cmd.Parameters.Clear();
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @id";
    cmd.Parameters.AddWithValue("@amount", amount);
    cmd.Parameters.AddWithValue("@id", accountId);
    cmd.ExecuteNonQuery();
    
    tx.Commit();
    return true;
}
else
{
    tx.Rollback();
    return false;
}

FOR SHARE (Shared Lock)

Use FOR SHARE when you want to ensure the row isn't modified by others, but you don't plan to modify it yourself. Multiple transactions can hold shared locks on the same row.

-- Lock for reading (prevent modifications)
SELECT * FROM Products WHERE Id = 1 FOR SHARE;

-- Other transactions can also SELECT ... FOR SHARE
-- But UPDATE/DELETE will block until we commit

Typical use case: Ensuring data consistency for calculations or validations without intending to modify.

using var tx = connection.BeginTransaction();
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

// Lock related rows to ensure consistency
cmd.CommandText = "SELECT * FROM OrderItems WHERE OrderId = @id FOR SHARE";
cmd.Parameters.AddWithValue("@id", orderId);

using var reader = cmd.ExecuteReader();
var items = new List<OrderItem>();
while (reader.Read())
{
    items.Add(MapToOrderItem(reader));
}
reader.Close();

// Calculate total (no one can modify items during calculation)
var total = items.Sum(i => i.Price * i.Quantity);

// Use the consistent total
cmd.Parameters.Clear();
cmd.CommandText = "UPDATE Orders SET Total = @total WHERE Id = @id";
cmd.Parameters.AddWithValue("@total", total);
cmd.Parameters.AddWithValue("@id", orderId);
cmd.ExecuteNonQuery();

tx.Commit();

NOWAIT

By default, if a lock cannot be acquired, the transaction waits until the lock becomes available. Use NOWAIT to fail immediately instead.

-- Try to lock, fail immediately if already locked
SELECT * FROM Accounts WHERE Id = 1 FOR UPDATE NOWAIT;
using var tx = connection.BeginTransaction();
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;

try
{
    cmd.CommandText = "SELECT * FROM Accounts WHERE Id = @id FOR UPDATE NOWAIT";
    cmd.Parameters.AddWithValue("@id", accountId);
    
    using var reader = cmd.ExecuteReader();
    // Process...
}
catch (WitDbException ex) when (ex.Message.Contains("lock"))
{
    // Row is locked by another transaction
    Console.WriteLine("Account is being modified by another user, please try again");
    tx.Rollback();
    return false;
}

When to use NOWAIT:

  • User-interactive applications where waiting is unacceptable
  • Retry-based architectures where you want to try a different row
  • Timeouts are critical

SKIP LOCKED

Use SKIP LOCKED to skip rows that are already locked by other transactions. This is useful for implementing work queues.

-- Get the next available (unlocked) row
SELECT * FROM Tasks 
WHERE Status = 'pending' 
ORDER BY CreatedAt 
LIMIT 1 
FOR UPDATE SKIP LOCKED;

Typical use case: Job queues where multiple workers compete for tasks.

public async Task<Job?> ClaimNextJobAsync()
{
    await using var connection = new WitDbConnection(_connectionString);
    await connection.OpenAsync();
    
    await using var tx = await connection.BeginTransactionAsync();
    await using var cmd = connection.CreateCommand();
    cmd.Transaction = tx;
    
    // Get next unlocked job
    cmd.CommandText = @"
        SELECT Id, Data FROM Jobs 
        WHERE Status = 'pending' 
        ORDER BY Priority DESC, CreatedAt 
        LIMIT 1 
        FOR UPDATE SKIP LOCKED";
    
    await using var reader = await cmd.ExecuteReaderAsync();
    
    if (!await reader.ReadAsync())
    {
        // No jobs available (all pending jobs are locked by other workers)
        await tx.RollbackAsync();
        return null;
    }
    
    var jobId = reader.GetInt64(0);
    var data = reader.GetString(1);
    await reader.CloseAsync();
    
    // Mark as claimed
    cmd.CommandText = "UPDATE Jobs SET Status = 'processing', WorkerId = @worker WHERE Id = @id";
    cmd.Parameters.AddWithValue("@worker", Environment.MachineName);
    cmd.Parameters.AddWithValue("@id", jobId);
    await cmd.ExecuteNonQueryAsync();
    
    await tx.CommitAsync();
    
    return new Job { Id = jobId, Data = data };
}

Lock Timeout

Configure how long to wait for a lock before giving up:

// Via connection string
"Data Source=app.witdb;Lock Timeout=60"  // 60 seconds

// Via builder
var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithLockTimeout(TimeSpan.FromSeconds(60))
    .Build();

When the timeout expires, a TimeoutException is thrown.

Deadlock Detection

When two transactions wait for each other's locks, a deadlock occurs:

[[Svg Src="./witdatabase-deadlock.svg" Alt="witdatabase-deadlock"]]

WitDatabase automatically detects deadlocks and aborts one transaction (the "victim"):

try
{
    cmd.CommandText = "SELECT * FROM Accounts WHERE Id = @id FOR UPDATE";
    cmd.Parameters.AddWithValue("@id", accountId);
    cmd.ExecuteReader();
    
    // ... more operations
    
    tx.Commit();
}
catch (WitDbDeadlockException)
{
    tx.Rollback();
    
    // Retry the entire transaction
    return await RetryTransactionAsync();
}

Deadlock prevention tips:

  • Always lock rows in the same order (e.g., by primary key ascending)
  • Keep transactions short
  • Acquire all needed locks at the start of the transaction
  • Use NOWAIT and handle failures instead of waiting

Row Locking Best Practices

Do:

  • Use FOR UPDATE when you plan to modify the row
  • Use FOR SHARE when you only need to prevent modifications
  • Use NOWAIT in interactive applications
  • Use SKIP LOCKED for work queues
  • Handle deadlocks with retry logic
  • Keep lock durations short

Don't:

  • Hold locks during user think time
  • Lock more rows than necessary
  • Mix locking strategies arbitrarily
  • Ignore deadlock possibilities
  • Use row locks when MVCC is sufficient

8. Best Practices

This section summarizes best practices for working with transactions in WitDatabase.

Keep Transactions Short

Long-running transactions cause problems:

  • Hold locks that block other transactions
  • Prevent MVCC garbage collection
  • Increase conflict probability
  • Risk timeout and rollback
// BAD: Long transaction
using var tx = connection.BeginTransaction();
var data = LoadAllData(cmd);           // Fast
ProcessData(data);                      // SLOW - 30 seconds!
SaveResults(cmd, data);                 // Fast
tx.Commit();
// Transaction held for 30+ seconds

// GOOD: Minimal transaction scope
var data = LoadAllData(connection);     // Own transaction (auto-commit)
ProcessData(data);                      // No transaction
using var tx = connection.BeginTransaction();
SaveResults(cmd, data);                 // Short transaction
tx.Commit();
// Transaction held for milliseconds

Don't Hold Transactions During User Interaction

Never wait for user input while a transaction is open:

// BAD: Transaction during user interaction
using var tx = connection.BeginTransaction();
var order = LoadOrder(cmd, orderId);
ShowConfirmationDialog(order);          // User thinks for 5 minutes!
ConfirmOrder(cmd, orderId);
tx.Commit();

// GOOD: Transaction after user confirms
var order = LoadOrder(connection, orderId);  // No transaction
if (ShowConfirmationDialog(order))
{
    using var tx = connection.BeginTransaction();
    ConfirmOrder(cmd, orderId);
    tx.Commit();
}

Handle Failures Gracefully

Always use try-catch and ensure rollback:

using var tx = connection.BeginTransaction();

try
{
    // Operations that may fail
    ExecuteBusinessLogic(cmd, tx);
    tx.Commit();
}
catch (WitDbConcurrencyException)
{
    tx.Rollback();
    // Retry with fresh data or inform user
    throw new ConflictException("Data was modified by another user");
}
catch (WitDbDeadlockException)
{
    tx.Rollback();
    // Automatic retry is usually appropriate
    return await RetryAsync();
}
catch (Exception)
{
    tx.Rollback();
    throw;
}

Choose the Right Isolation Level

Scenario Isolation Level Why
Simple CRUD READ COMMITTED Default, minimal overhead
Batch reporting SNAPSHOT Consistent without blocking
Financial transactions SERIALIZABLE Critical accuracy
Dashboard/monitoring READ UNCOMMITTED Speed over accuracy
Multi-read calculations REPEATABLE READ Consistent values

Use Savepoints for Complex Operations

Break complex transactions into recoverable sections:

using var tx = connection.BeginTransaction();

try
{
    CreateOrder(cmd, order);
    
    tx.Save("order_created");
    
    try
    {
        ProcessPayment(cmd, payment);
    }
    catch (PaymentException)
    {
        tx.Rollback("order_created");
        // Mark order as payment failed instead of rolling back entirely
        MarkPaymentFailed(cmd, order.Id);
    }
    
    tx.Commit();
}
catch
{
    tx.Rollback();
    throw;
}

Avoid Nested Transaction Attempts

WitDatabase doesn't support true nested transactions. Use savepoints instead:

// BAD: Nested transactions (throws exception)
using var tx1 = connection.BeginTransaction();
using var tx2 = connection.BeginTransaction(); // Error!

// GOOD: Use savepoints
using var tx = connection.BeginTransaction();
tx.Save("inner");
// ... operations ...
tx.Rollback("inner"); // Partial rollback
tx.Commit();

Lock in Consistent Order

To prevent deadlocks, always acquire locks in the same order:

// BAD: Inconsistent order
// Thread 1: Locks A, then B
// Thread 2: Locks B, then A
// → Deadlock possible!

// GOOD: Always lock in ID order
var ids = new[] { accountId1, accountId2 }.OrderBy(id => id).ToArray();

foreach (var id in ids)
{
    cmd.CommandText = "SELECT * FROM Accounts WHERE Id = @id FOR UPDATE";
    cmd.Parameters.Clear();
    cmd.Parameters.AddWithValue("@id", id);
    cmd.ExecuteReader();
}

Use Appropriate Timeout

Configure lock timeout based on your application:

// Interactive applications: short timeout, fail fast
"Data Source=app.witdb;Lock Timeout=5"

// Batch processing: longer timeout
"Data Source=app.witdb;Lock Timeout=300"

9. Quick Reference

Transaction Methods

ADO.NET (WitDbConnection):

Method Description
BeginTransaction() Start transaction (ReadCommitted)
BeginTransaction(IsolationLevel) Start with specific isolation
BeginTransactionAsync() Async start
Commit() Commit transaction
CommitAsync() Async commit
Rollback() Rollback transaction
RollbackAsync() Async rollback
Save(name) Create savepoint
Rollback(name) Rollback to savepoint
Release(name) Release savepoint

SQL Statements:

Statement Description
BEGIN TRANSACTION Start transaction
COMMIT Commit transaction
ROLLBACK Rollback transaction
SAVEPOINT name Create savepoint
ROLLBACK TO SAVEPOINT name Rollback to savepoint
RELEASE SAVEPOINT name Release savepoint
SET TRANSACTION ISOLATION LEVEL level Set isolation

Isolation Levels

Level Dirty Read Non-Repeatable Phantom Use Case
READ UNCOMMITTED Yes Yes Yes Approximate counts
READ COMMITTED No Yes Yes Default, simple CRUD
REPEATABLE READ No No Yes Consistent reads
SERIALIZABLE No No No Critical operations
SNAPSHOT No No No Reports, OLTP mixed

Row Locking

Syntax Behavior
FOR UPDATE Exclusive lock, block others
FOR SHARE Shared lock, allow other reads
FOR UPDATE NOWAIT Fail if locked
FOR UPDATE SKIP LOCKED Skip locked rows

Configuration

Connection String:

Data Source=app.witdb;
Transactions=true;
MVCC=true;
Isolation Level=Snapshot;
Lock Timeout=30;
Journal=wal

Builder Pattern:

var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithBTree()
    .WithTransactions()
    .WithMvcc()
    .WithDefaultIsolationLevel(WitIsolationLevel.Snapshot)
    .WithLockTimeout(TimeSpan.FromSeconds(30))
    .WithWalJournal()
    .Build();

Exception Types

Exception Cause Recovery
WitDbConcurrencyException Write conflict in MVCC Retry transaction
WitDbDeadlockException Deadlock detected Retry transaction
TimeoutException Lock timeout Retry or fail
WitDbConstraintException Constraint violation Fix data or handle