Transactions


Overview

ic-dbms supports ACID transactions, allowing you to group multiple database operations into a single atomic unit. Either all operations succeed and are committed together, or none of them take effect.

Key features:

  • Atomicity: All operations in a transaction succeed or fail together
  • Consistency: Data integrity constraints are maintained
  • Isolation: Transactions are isolated from each other
  • Durability: Committed changes persist across canister upgrades

Transaction Lifecycle

Begin Transaction

Start a new transaction using begin_transaction():

use ic_dbms_client::{IcDbmsCanisterClient, Client as _};

let client = IcDbmsCanisterClient::new(canister_id);

// Begin a new transaction
let tx_id: u64 = client.begin_transaction().await?;
println!("Started transaction: {}", tx_id);

The returned transaction ID is used for all subsequent operations within this transaction.

Perform Operations

Pass the transaction ID to CRUD operations:

// Insert within transaction
client
    .insert::<User>(User::table_name(), user, Some(tx_id))
    .await??;

// Update within transaction
client
    .update::<User>(User::table_name(), update, Some(tx_id))
    .await??;

// Delete within transaction
client
    .delete::<User>(User::table_name(), DeleteBehavior::Restrict, Some(filter), Some(tx_id))
    .await??;

// Select within transaction (sees uncommitted changes)
let users = client
    .select::<User>(User::table_name(), query, Some(tx_id))
    .await??;

Note: Operations within a transaction are visible to subsequent operations in the same transaction, but not to other callers until committed.

Commit

Commit the transaction to make all changes permanent:

// Commit the transaction
client.commit(tx_id).await??;
println!("Transaction committed successfully");

After commit:

  • All changes become visible to other callers
  • The transaction ID becomes invalid
  • Changes persist across canister upgrades

Rollback

Rollback the transaction to discard all changes:

// Rollback the transaction
client.rollback(tx_id).await??;
println!("Transaction rolled back");

After rollback:

  • All changes within the transaction are discarded
  • The transaction ID becomes invalid
  • The database state is as if the transaction never happened

ACID Properties

Atomicity

All operations in a transaction are treated as a single unit. If any operation fails, the entire transaction can be rolled back:

let tx_id = client.begin_transaction().await?;

// First operation succeeds
client.insert::<User>(User::table_name(), user1, Some(tx_id)).await??;

// Second operation fails (e.g., primary key conflict)
let result = client.insert::<User>(User::table_name(), user2_duplicate, Some(tx_id)).await?;

if result.is_err() {
    // Rollback everything - user1 is also discarded
    client.rollback(tx_id).await??;
}

Consistency

Transactions maintain data integrity:

  • Primary key uniqueness is enforced
  • Foreign key constraints are checked
  • Validators run on all data
  • Sanitizers are applied
let tx_id = client.begin_transaction().await?;

// This will fail if referenced user doesn't exist
let post = PostInsertRequest {
    id: 1.into(),
    title: "My Post".into(),
    author_id: 999.into(),  // Non-existent user
};

let result = client.insert::<Post>(Post::table_name(), post, Some(tx_id)).await?;
// Returns Err(BrokenForeignKeyReference)

Isolation

Changes made within a transaction are not visible to other callers until committed:

// Caller A starts a transaction
let tx_a = client_a.begin_transaction().await?;
client_a.insert::<User>(User::table_name(), new_user, Some(tx_a)).await??;

// Caller B queries - does NOT see the new user
let users = client_b.select::<User>(User::table_name(), query, None).await??;
assert!(!users.iter().any(|u| u.id == new_user.id));

// Caller A commits
client_a.commit(tx_a).await??;

// Now Caller B can see the user
let users = client_b.select::<User>(User::table_name(), query, None).await??;
assert!(users.iter().any(|u| u.id == new_user.id));

Durability

Committed transactions persist across canister upgrades. ic-dbms uses stable memory to ensure data survives upgrades.


Transaction Ownership

Transactions are owned by the principal that created them. Only the owner can:

  • Perform operations within the transaction
  • Commit the transaction
  • Rollback the transaction
// Principal A creates transaction
let tx_id = client_a.begin_transaction().await?;

// Principal A can use it
client_a.insert::<User>(User::table_name(), user, Some(tx_id)).await??;  // OK

// Principal B cannot use it
let result = client_b.insert::<User>(User::table_name(), user, Some(tx_id)).await?;
// Returns Err(TransactionNotFound) or similar error

// Only Principal A can commit
client_a.commit(tx_id).await??;  // OK

Error Handling

Handling Failures

When an operation fails within a transaction, you should typically rollback:

let tx_id = client.begin_transaction().await?;

async fn process_order(client: &impl Client, tx_id: u64) -> Result<(), IcDbmsError> {
    // Multiple operations that should succeed together
    client.insert::<Order>(Order::table_name(), order, Some(tx_id)).await??;
    client.update::<Inventory>(Inventory::table_name(), update, Some(tx_id)).await??;
    client.insert::<OrderItem>(OrderItem::table_name(), item, Some(tx_id)).await??;
    Ok(())
}

match process_order(&client, tx_id).await {
    Ok(()) => {
        client.commit(tx_id).await??;
        println!("Order processed successfully");
    }
    Err(e) => {
        client.rollback(tx_id).await??;
        println!("Order failed, rolled back: {:?}", e);
    }
}

Transaction Errors

Error Cause
TransactionNotFound Invalid transaction ID or transaction already completed
TransactionNotOwned Caller doesn’t own the transaction
use ic_dbms_api::prelude::{IcDbmsError, TransactionError};

let result = client.commit(invalid_tx_id).await?;
match result {
    Ok(()) => println!("Committed"),
    Err(IcDbmsError::Transaction(TransactionError::NotFound)) => {
        println!("Transaction not found or already completed");
    }
    Err(e) => println!("Other error: {:?}", e),
}

Best Practices

1. Keep transactions short

Long-running transactions hold resources and block other operations:

// GOOD: Prepare data outside transaction
let users_to_insert = prepare_users();

let tx_id = client.begin_transaction().await?;
for user in users_to_insert {
    client.insert::<User>(User::table_name(), user, Some(tx_id)).await??;
}
client.commit(tx_id).await??;

// BAD: Doing expensive work inside transaction
let tx_id = client.begin_transaction().await?;
for raw_data in large_dataset {
    let user = expensive_parsing(raw_data);  // Don't do this in transaction
    client.insert::<User>(User::table_name(), user, Some(tx_id)).await??;
}
client.commit(tx_id).await??;

2. Always handle rollback

Ensure transactions are either committed or rolled back:

let tx_id = client.begin_transaction().await?;

let result = async {
    client.insert::<User>(User::table_name(), user1, Some(tx_id)).await??;
    client.insert::<User>(User::table_name(), user2, Some(tx_id)).await??;
    Ok::<(), IcDbmsError>(())
}.await;

match result {
    Ok(()) => client.commit(tx_id).await??,
    Err(_) => client.rollback(tx_id).await??,
}

Group operations that should succeed or fail together:

// GOOD: Related operations in transaction
let tx_id = client.begin_transaction().await?;
client.insert::<Order>(Order::table_name(), order, Some(tx_id)).await??;
client.insert::<Payment>(Payment::table_name(), payment, Some(tx_id)).await??;
client.update::<Inventory>(Inventory::table_name(), inv_update, Some(tx_id)).await??;
client.commit(tx_id).await??;

// BAD: Unrelated operations in transaction (unnecessary)
let tx_id = client.begin_transaction().await?;
client.insert::<UserPreferences>(prefs_table, prefs, Some(tx_id)).await??;
client.insert::<AuditLog>(log_table, log, Some(tx_id)).await??;  // Unrelated
client.commit(tx_id).await??;

4. Don’t mix transactional and non-transactional operations

let tx_id = client.begin_transaction().await?;

// GOOD: All operations use the transaction
client.insert::<Order>(Order::table_name(), order, Some(tx_id)).await??;
client.insert::<OrderItem>(OrderItem::table_name(), item, Some(tx_id)).await??;

// BAD: Mixing transaction and non-transaction
client.insert::<Order>(Order::table_name(), order, Some(tx_id)).await??;
client.insert::<AuditLog>(AuditLog::table_name(), log, None).await??;  // Not in transaction!

Examples

Bank Transfer

Transfer money between accounts atomically:

async fn transfer(
    client: &impl Client,
    from_account: u32,
    to_account: u32,
    amount: Decimal,
) -> Result<(), IcDbmsError> {
    let tx_id = client.begin_transaction().await?;

    // Deduct from source account
    let deduct = AccountUpdateRequest::builder()
        .decrease_balance(amount)
        .filter(Filter::eq("id", Value::Uint32(from_account.into())))
        .build();
    client.update::<Account>(Account::table_name(), deduct, Some(tx_id)).await??;

    // Add to destination account
    let add = AccountUpdateRequest::builder()
        .increase_balance(amount)
        .filter(Filter::eq("id", Value::Uint32(to_account.into())))
        .build();
    client.update::<Account>(Account::table_name(), add, Some(tx_id)).await??;

    // Record the transfer
    let transfer_record = TransferInsertRequest {
        id: Uuid::new_v4().into(),
        from_account: from_account.into(),
        to_account: to_account.into(),
        amount,
        timestamp: DateTime::now(),
    };
    client.insert::<Transfer>(Transfer::table_name(), transfer_record, Some(tx_id)).await??;

    // Commit atomically
    client.commit(tx_id).await??;
    Ok(())
}

Order Processing

Process an order with inventory update:

async fn process_order(
    client: &impl Client,
    order: OrderInsertRequest,
    items: Vec<OrderItemInsertRequest>,
) -> Result<u32, Box<dyn std::error::Error>> {
    let tx_id = client.begin_transaction().await?;

    // Insert the order
    client.insert::<Order>(Order::table_name(), order.clone(), Some(tx_id)).await??;

    // Insert order items and update inventory
    for item in items {
        // Insert order item
        client.insert::<OrderItem>(OrderItem::table_name(), item.clone(), Some(tx_id)).await??;

        // Decrease inventory
        let inv_update = InventoryUpdateRequest::builder()
            .decrease_quantity(item.quantity)
            .filter(Filter::eq("product_id", Value::Uint32(item.product_id)))
            .build();

        let updated = client.update::<Inventory>(
            Inventory::table_name(),
            inv_update,
            Some(tx_id)
        ).await??;

        if updated == 0 {
            // Product not in inventory, rollback
            client.rollback(tx_id).await??;
            return Err("Product not found in inventory".into());
        }
    }

    // All successful, commit
    client.commit(tx_id).await??;
    Ok(order.id.into())
}