Transaction Patterns Tutorial

Master ACID transactions and isolation levels in Geode in this hands-on 30-minute tutorial.

Prerequisites

  • Completed MATCH Basics Tutorial
  • Geode server running (geode serve)
  • Access to Geode shell (geode shell)
  • Understanding of basic SQL transactions (helpful but not required)

Tutorial Overview

Time: 30 minutes Difficulty: Intermediate Topics: ACID properties, isolation levels, savepoints, deadlock handling

By the end of this tutorial, you’ll be able to:

  • Use transactions for data consistency
  • Choose appropriate isolation levels
  • Handle concurrent updates safely
  • Use savepoints for partial rollback
  • Debug and resolve deadlocks
  • Implement common transaction patterns

What are Transactions?

Transactions group multiple operations into a single atomic unit. Either all operations succeed (commit) or none do (rollback), ensuring data consistency.

ACID Properties

  • Atomicity: All or nothing - operations complete fully or not at all
  • Consistency: Data remains valid before and after transaction
  • Isolation: Concurrent transactions don’t interfere
  • Durability: Committed data survives crashes

Step 1: Basic Transactions

Start, Commit, Rollback

-- Start a transaction
START TRANSACTION;

-- Create data
CREATE (:Account {id: 1, name: 'Alice', balance: 1000});
CREATE (:Account {id: 2, name: 'Bob', balance: 500});

-- Commit to make changes permanent
COMMIT;

Verify:

MATCH (a:Account)
RETURN a.name, a.balance;

Expected output:

name  | balance
------|--------
Alice | 1000
Bob   | 500

Rollback on Error

-- Start transaction
START TRANSACTION;

-- Create node
CREATE (:Account {id: 3, name: 'Carol', balance: 750});

-- Decide to cancel
ROLLBACK;

-- Verify Carol was NOT created
MATCH (a:Account {name: 'Carol'})
RETURN a;
-- Returns 0 rows

Client Library Examples

// Start transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    log.Fatal(err)
}

// Create accounts
_, err = tx.ExecContext(ctx, `CREATE (:Account {id: ?, name: ?, balance: ?})`, 1, "Alice", 1000)
if err != nil {
    tx.Rollback()
    log.Fatal(err)
}

_, err = tx.ExecContext(ctx, `CREATE (:Account {id: ?, name: ?, balance: ?})`, 2, "Bob", 500)
if err != nil {
    tx.Rollback()
    log.Fatal(err)
}

// Commit
if err := tx.Commit(); err != nil {
    log.Fatal(err)
}
async with client.connection() as conn:
    await conn.begin()
    try:
        await conn.execute(
            "CREATE (:Account {id: $id, name: $name, balance: $balance})",
            {"id": 1, "name": "Alice", "balance": 1000}
        )
        await conn.execute(
            "CREATE (:Account {id: $id, name: $name, balance: $balance})",
            {"id": 2, "name": "Bob", "balance": 500}
        )
        await conn.commit()
    except Exception:
        await conn.rollback()
        raise
conn.begin().await?;

match async {
    conn.query_with_params(
        "CREATE (:Account {id: $id, name: $name, balance: $balance})",
        &[("id", Value::int(1)), ("name", Value::string("Alice")), ("balance", Value::int(1000))].into()
    ).await?;

    conn.query_with_params(
        "CREATE (:Account {id: $id, name: $name, balance: $balance})",
        &[("id", Value::int(2)), ("name", Value::string("Bob")), ("balance", Value::int(500))].into()
    ).await?;

    conn.commit().await
}.await {
    Ok(_) => println!("Accounts created"),
    Err(e) => {
        conn.rollback().await?;
        return Err(e);
    }
}
await client.withTransaction(async (tx) => {
    await tx.exec(
        'CREATE (:Account {id: $id, name: $name, balance: $balance})',
        { params: { id: 1, name: 'Alice', balance: 1000 } }
    );
    await tx.exec(
        'CREATE (:Account {id: $id, name: $name, balance: $balance})',
        { params: { id: 2, name: 'Bob', balance: 500 } }
    );
    // Auto-commits on success, auto-rollback on error
});
try client.sendBegin();
_ = try client.receiveMessage(30000);

// Create accounts with parameterized queries
var params = std.json.ObjectMap.init(allocator);
defer params.deinit();
try params.put("id", .{ .integer = 1 });
try params.put("name", .{ .string = "Alice" });
try params.put("balance", .{ .integer = 1000 });

try client.sendRunGql(1,
    "CREATE (:Account {id: $id, name: $name, balance: $balance})",
    .{ .object = params });
_ = try client.receiveMessage(30000);

params.clearRetainingCapacity();
try params.put("id", .{ .integer = 2 });
try params.put("name", .{ .string = "Bob" });
try params.put("balance", .{ .integer = 500 });

try client.sendRunGql(2,
    "CREATE (:Account {id: $id, name: $name, balance: $balance})",
    .{ .object = params });
_ = try client.receiveMessage(30000);

try client.sendCommit();
_ = try client.receiveMessage(30000);

What You Learned

  • START TRANSACTION begins a transaction
  • COMMIT makes changes permanent
  • ROLLBACK discards all changes
  • Changes are invisible to other connections until commit

Step 2: Money Transfer Pattern

Atomic Transfer

-- Transfer $100 from Alice to Bob
START TRANSACTION;

-- Debit sender
MATCH (alice:Account {name: 'Alice'})
SET alice.balance = alice.balance - 100;

-- Credit receiver
MATCH (bob:Account {name: 'Bob'})
SET bob.balance = bob.balance + 100;

-- Both updates happen or neither does
COMMIT;

-- Verify
MATCH (a:Account)
RETURN a.name, a.balance
ORDER BY a.name;

Expected output:

name  | balance
------|--------
Alice | 900
Bob   | 600

With Validation

-- Transfer with insufficient funds check
START TRANSACTION;

-- Check sender balance
MATCH (alice:Account {name: 'Alice'})
WHERE alice.balance >= 200
SET alice.balance = alice.balance - 200;

-- If no rows matched, balance was insufficient
-- Transaction still continues but with no changes

-- Credit receiver
MATCH (bob:Account {name: 'Bob'})
SET bob.balance = bob.balance + 200;

COMMIT;

Error Handling Pattern

func transferMoney(ctx context.Context, db *sql.DB, from, to string, amount int) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }

    // Check balance
    var balance int
    err = tx.QueryRowContext(ctx,
        "MATCH (a:Account {name: ?}) RETURN a.balance", from).Scan(&balance)
    if err != nil {
        tx.Rollback()
        return err
    }
    if balance < amount {
        tx.Rollback()
        return fmt.Errorf("insufficient funds in %s", from)
    }

    // Debit sender
    _, err = tx.ExecContext(ctx,
        "MATCH (a:Account {name: ?}) SET a.balance = a.balance - ?", from, amount)
    if err != nil {
        tx.Rollback()
        return err
    }

    // Credit receiver
    _, err = tx.ExecContext(ctx,
        "MATCH (a:Account {name: ?}) SET a.balance = a.balance + ?", to, amount)
    if err != nil {
        tx.Rollback()
        return err
    }

    return tx.Commit()
}
async def transfer_money(from_account, to_account, amount):
    async with client.connection() as conn:
        try:
            await conn.begin()

            page, _ = await conn.query(
                "MATCH (a:Account {name: $from}) RETURN a.balance AS balance",
                {"from": from_account},
            )

            if not page.rows or page.rows[0]["balance"].as_int < amount:
                raise ValueError(f"Insufficient funds in {from_account}")

            await conn.execute(
                "MATCH (a:Account {name: $from}) SET a.balance = a.balance - $amount",
                {"from": from_account, "amount": amount},
            )
            await conn.execute(
                "MATCH (a:Account {name: $to}) SET a.balance = a.balance + $amount",
                {"to": to_account, "amount": amount},
            )

            await conn.commit()
        except Exception:
            await conn.rollback()
            raise
async fn transfer_money(conn: &mut Connection, from: &str, to: &str, amount: i64) -> Result<()> {
    conn.begin().await?;

    // Check balance
    let params = [("from", Value::string(from))].into();
    let (page, _) = conn.query_with_params(
        "MATCH (a:Account {name: $from}) RETURN a.balance AS balance",
        &params
    ).await?;

    let balance = page.rows.first()
        .and_then(|r| r.get("balance"))
        .map(|v| v.as_int().unwrap_or(0))
        .unwrap_or(0);

    if balance < amount {
        conn.rollback().await?;
        return Err("Insufficient funds".into());
    }

    // Transfer
    let mut params = HashMap::new();
    params.insert("from".to_string(), Value::string(from));
    params.insert("to".to_string(), Value::string(to));
    params.insert("amount".to_string(), Value::int(amount));

    conn.query_with_params("MATCH (a:Account {name: $from}) SET a.balance = a.balance - $amount", &params).await?;
    conn.query_with_params("MATCH (a:Account {name: $to}) SET a.balance = a.balance + $amount", &params).await?;

    conn.commit().await
}
async function transferMoney(from: string, to: string, amount: number) {
    const conn = await client.getConnection();
    const tx = await conn.begin();

    try {
        // Check balance
        const [row] = await tx.queryAll(
            'MATCH (a:Account {name: $from}) RETURN a.balance AS balance',
            { params: { from } }
        );
        const balance = row?.get('balance')?.asNumber ?? 0;

        if (balance < amount) {
            throw new Error(`Insufficient funds in ${from}`);
        }

        // Debit sender
        await tx.exec(
            'MATCH (a:Account {name: $from}) SET a.balance = a.balance - $amount',
            { params: { from, amount } }
        );

        // Credit receiver
        await tx.exec(
            'MATCH (a:Account {name: $to}) SET a.balance = a.balance + $amount',
            { params: { to, amount } }
        );

        await tx.commit();
    } catch (e) {
        await tx.rollback();
        throw e;
    } finally {
        await client.releaseConnection(conn);
    }
}
fn transferMoney(client: *GeodeClient, allocator: std.mem.Allocator, from: []const u8, to: []const u8, amount: i64) !void {
    try client.sendBegin();
    _ = try client.receiveMessage(30000);

    var params = std.json.ObjectMap.init(allocator);
    defer params.deinit();
    try params.put("from", .{ .string = from });
    try params.put("to", .{ .string = to });
    try params.put("amount", .{ .integer = amount });

    // Check balance
    try client.sendRunGql(1,
        "MATCH (a:Account {name: $from}) RETURN a.balance AS balance",
        .{ .object = params });
    const schema = try client.receiveMessage(30000);
    allocator.free(schema);

    try client.sendPull(1, 1000);
    const balance_result = try client.receiveMessage(30000);
    defer allocator.free(balance_result);
    // Parse and validate balance >= amount before proceeding

    // Debit sender
    try client.sendRunGql(2,
        "MATCH (a:Account {name: $from}) SET a.balance = a.balance - $amount",
        .{ .object = params });
    _ = try client.receiveMessage(30000);

    // Credit receiver
    try client.sendRunGql(3,
        "MATCH (a:Account {name: $to}) SET a.balance = a.balance + $amount",
        .{ .object = params });
    _ = try client.receiveMessage(30000);

    try client.sendCommit();
    _ = try client.receiveMessage(30000);
}

Step 3: Savepoints (Partial Rollback)

Create Savepoints

START TRANSACTION;

-- Create first account
CREATE (:Account {id: 4, name: 'David', balance: 1500});
SAVEPOINT sp1;

-- Create second account
CREATE (:Account {id: 5, name: 'Emma', balance: 2000});
SAVEPOINT sp2;

-- Create third account
CREATE (:Account {id: 6, name: 'Frank', balance: 500});

-- Rollback to sp2 (Frank not created)
ROLLBACK TO SAVEPOINT sp2;

-- Rollback to sp1 (Emma also not created)
ROLLBACK TO SAVEPOINT sp1;

-- Commit (only David created)
COMMIT;

-- Verify
MATCH (a:Account)
WHERE a.id IN [4, 5, 6]
RETURN a.name;

Expected output:

name
------
David

Multi-Step Operation

START TRANSACTION;

-- Step 1: Create user
CREATE (:User {id: 'u1', name: 'John Doe', email: 'john@example.com'});
SAVEPOINT user_created;

-- Step 2: Create preferences
CREATE (:Preferences {user_id: 'u1', theme: 'dark', notifications: true});
SAVEPOINT prefs_created;

-- Step 3: Create profile (fails validation)
CREATE (:Profile {user_id: 'u1', bio: 'This bio is way too long and exceeds the 280 character limit...'});

-- Oops, bio too long - rollback profile only
ROLLBACK TO SAVEPOINT prefs_created;

-- Create valid profile
CREATE (:Profile {user_id: 'u1', bio: 'Software developer'});

-- Commit all
COMMIT;

What You Learned

  • SAVEPOINT name creates a checkpoint
  • ROLLBACK TO SAVEPOINT name undoes to that point
  • Earlier savepoints remain valid
  • Useful for complex multi-step operations

Step 4: Isolation Levels

Read Committed (Default)

-- Transaction 1
START TRANSACTION WITH ISOLATION LEVEL READ COMMITTED;
MATCH (a:Account {name: 'Alice'})
RETURN a.balance;  -- Returns 900

-- (Transaction 2 commits change: balance = 800)

MATCH (a:Account {name: 'Alice'})
RETURN a.balance;  -- Returns 800 (sees committed change)

COMMIT;

Behavior: Sees other transactions’ committed changes during execution.

Repeatable Read

-- Transaction 1
START TRANSACTION WITH ISOLATION LEVEL REPEATABLE READ;
MATCH (a:Account {name: 'Alice'})
RETURN a.balance;  -- Returns 900

-- (Transaction 2 commits change: balance = 800)

MATCH (a:Account {name: 'Alice'})
RETURN a.balance;  -- Still returns 900 (repeatable read)

COMMIT;

Behavior: Sees consistent snapshot throughout transaction.

Serializable

-- Transaction 1
START TRANSACTION WITH ISOLATION LEVEL SERIALIZABLE;

-- Read accounts in range
MATCH (a:Account)
WHERE a.balance > 500
RETURN count(a);  -- Returns 2

-- (Transaction 2 tries to insert Account with balance=600)
-- Blocked until Transaction 1 completes (prevents phantom reads)

COMMIT;

Behavior: Prevents phantom reads, full serializability.

Isolation Level Comparison

LevelDirty ReadNon-Repeatable ReadPhantom ReadPerformance
Read Uncommitted❌ Possible❌ Possible❌ PossibleFastest
Read Committed✅ Prevented❌ Possible❌ PossibleFast
Repeatable Read✅ Prevented✅ Prevented❌ PossibleMedium
Serializable✅ Prevented✅ Prevented✅ PreventedSlowest

Choosing Isolation Level

# Python client - Set isolation level
from geode_client import Client

client = Client(host="localhost", port=3141)

async def transfer_with_isolation(conn, from_acc, to_acc, amount):
    """Transfer using the server's default isolation level."""

    await conn.begin()
    try:
        page, _ = await conn.query("""
            MATCH (from:Account {name: $from})
            RETURN from.balance AS balance
        """, {'from': from_acc})

        balance = page.rows[0]["balance"].raw_value if page.rows else 0
        if balance < amount:
            raise ValueError("Insufficient funds")

        await conn.query("""
            MATCH (from:Account {name: $from})
            SET from.balance = from.balance - $amount
        """, {'from': from_acc, 'amount': amount})

        await conn.query("""
            MATCH (to:Account {name: $to})
            SET to.balance = to.balance + $amount
        """, {'to': to_acc, 'amount': amount})

        await conn.commit()
    except Exception:
        await conn.rollback()
        raise

# async with client.connection() as conn:
#     await transfer_with_isolation(conn, "Alice", "Bob", 100)

Step 5: Concurrent Updates

Lost Update Problem

-- Transaction 1 (T1)
START TRANSACTION;
MATCH (a:Account {name: 'Alice'})
SET a.balance = a.balance + 100;  -- Reads 900, sets 1000

-- Transaction 2 (T2) - runs concurrently
START TRANSACTION;
MATCH (a:Account {name: 'Alice'})
SET a.balance = a.balance + 50;   -- Reads 900, sets 950

-- T1 commits: balance = 1000
COMMIT;  -- (T1)

-- T2 commits: balance = 950 (overwrites T1's update!)
COMMIT;  -- (T2)

-- Lost update! Should be 1050

Solution: Use SERIALIZABLE

-- Transaction 1
START TRANSACTION WITH ISOLATION LEVEL SERIALIZABLE;
MATCH (a:Account {name: 'Alice'})
SET a.balance = a.balance + 100;
COMMIT;

-- Transaction 2 (blocks until T1 commits)
START TRANSACTION WITH ISOLATION LEVEL SERIALIZABLE;
MATCH (a:Account {name: 'Alice'})
SET a.balance = a.balance + 50;  -- Reads committed value from T1
COMMIT;

-- Correct: balance updated by both transactions

Optimistic Locking Pattern

-- Read with version
MATCH (a:Account {name: 'Alice'})
RETURN a.balance, a.version;

-- Update with version check
START TRANSACTION;
MATCH (a:Account {name: 'Alice'})
WHERE a.version = $expected_version
SET a.balance = a.balance + 100,
    a.version = a.version + 1;
COMMIT;

-- If no rows updated, version changed (concurrent update detected)

Step 6: Deadlock Handling

Deadlock Scenario

-- Transaction 1
START TRANSACTION;
MATCH (alice:Account {name: 'Alice'})
SET alice.balance = alice.balance - 50;

-- Transaction 2 (concurrent)
START TRANSACTION;
MATCH (bob:Account {name: 'Bob'})
SET bob.balance = bob.balance - 50;

-- Transaction 1 (waits for Bob)
MATCH (bob:Account {name: 'Bob'})
SET bob.balance = bob.balance + 50;

-- Transaction 2 (waits for Alice) - DEADLOCK!
MATCH (alice:Account {name: 'Alice'})
SET alice.balance = alice.balance + 50;

-- One transaction aborted automatically

Deadlock Prevention - Lock Ordering

-- Always acquire locks in consistent order (e.g., alphabetically)

-- Transaction 1
START TRANSACTION;
-- Lock Alice first, then Bob (alphabetical order)
MATCH (alice:Account {name: 'Alice'})
SET alice.balance = alice.balance - 50;

MATCH (bob:Account {name: 'Bob'})
SET bob.balance = bob.balance + 50;
COMMIT;

-- Transaction 2
START TRANSACTION;
-- Also lock Alice first, then Bob (same order!)
MATCH (alice:Account {name: 'Alice'})
SET alice.balance = alice.balance + 25;

MATCH (bob:Account {name: 'Bob'})
SET bob.balance = bob.balance - 25;
COMMIT;

-- No deadlock - consistent lock ordering

Retry Logic

# Python client - Retry on deadlock
import time
from geode_client import Client, QueryError

client = Client(host="localhost", port=3141)

async def transfer_with_retry(conn, from_acc, to_acc, amount, max_retries=3):
    """Transfer with deadlock retry logic"""

    for attempt in range(max_retries):
        try:
            await conn.begin()

            # Sort accounts to ensure consistent lock order
            accounts = sorted([from_acc, to_acc])

            # Acquire locks in consistent order
            for account in accounts:
                await conn.query("""
                    MATCH (a:Account {name: $name})
                    RETURN a.balance
                """, {'name': account})

            # Perform transfer
            await conn.query("""
                MATCH (from:Account {name: $from})
                SET from.balance = from.balance - $amount
            """, {'from': from_acc, 'amount': amount})

            await conn.query("""
                MATCH (to:Account {name: $to})
                SET to.balance = to.balance + $amount
            """, {'to': to_acc, 'amount': amount})

            await conn.commit()
            return  # Success

        except QueryError as e:
            await conn.rollback()

            if 'deadlock' in str(e).lower():
                if attempt < max_retries - 1:
                    # Exponential backoff
                    wait_time = (2 ** attempt) * 0.1
                    time.sleep(wait_time)
                    print(f"Deadlock detected, retrying (attempt {attempt + 2})...")
                    continue

            raise  # Re-raise if not deadlock or max retries exceeded

# Usage
# async with client.connection() as conn:
#     await transfer_with_retry(conn, "Alice", "Bob", 100)

Step 7: Common Transaction Patterns

Batch Insert with Rollback

START TRANSACTION;

-- Insert batch
UNWIND range(1, 1000) AS i
CREATE (:Product {id: i, name: 'Product ' + i, price: i * 10});

-- Verify count
MATCH (p:Product)
WITH count(p) AS product_count
WHERE product_count < 1000
CALL {
    ROLLBACK;
    RETURN 'Rolled back - insufficient products' AS status
}

-- Otherwise commit
COMMIT;

Audit Trail Pattern

START TRANSACTION;

-- Make change
MATCH (a:Account {name: 'Alice'})
SET a.balance = a.balance - 500;

-- Record audit log
CREATE (:AuditLog {
    timestamp: timestamp(),
    account: 'Alice',
    operation: 'withdrawal',
    amount: 500,
    new_balance: a.balance
});

COMMIT;

Cascading Deletes

START TRANSACTION;

-- Delete user
MATCH (u:User {id: 'u1'})
DELETE u;

-- Delete related data
MATCH (pref:Preferences {user_id: 'u1'})
DELETE pref;

MATCH (prof:Profile {user_id: 'u1'})
DELETE prof;

MATCH (post:Post)-[:AUTHORED_BY]->(:User {id: 'u1'})
DETACH DELETE post;

COMMIT;

Conditional Update

START TRANSACTION;

-- Update if condition met
MATCH (a:Account {name: 'Alice'})
WHERE a.balance >= 1000
SET a.status = 'premium',
    a.discount_rate = 0.1;

-- Create notification if updated
MATCH (a:Account {name: 'Alice'})
WHERE a.status = 'premium'
CREATE (:Notification {
    user: a.name,
    message: 'Congratulations! You are now a premium member',
    timestamp: timestamp()
});

COMMIT;

Step 8: Performance Considerations

Keep Transactions Short

-- Bad: Long-running transaction
START TRANSACTION;

MATCH (p:Product)
WHERE p.category = 'Electronics'
WITH collect(p) AS products
-- Processing takes 30 seconds...
UNWIND products AS p
SET p.processed = true;

COMMIT;
-- Holds locks for 30+ seconds!

-- Good: Shorter transactions
MATCH (p:Product)
WHERE p.category = 'Electronics'
RETURN collect(p.id) AS product_ids;

-- Process in application layer

-- Quick update transaction
START TRANSACTION;
UNWIND $product_ids AS id
MATCH (p:Product {id: id})
SET p.processed = true;
COMMIT;

Batch Operations

# Python client - Batch updates
from geode_client import Client

client = Client(host="localhost", port=3141)

async def batch_update(conn, product_ids, batch_size=100):
    """Update products in batches"""

    for i in range(0, len(product_ids), batch_size):
        batch = product_ids[i:i + batch_size]

        await conn.begin()

        try:
            await conn.query("""
                UNWIND $ids AS id
                MATCH (p:Product {id: id})
                SET p.processed = true
            """, {'ids': batch})

            await conn.commit()

        except Exception:
            await conn.rollback()
            raise

# Process 10,000 products in batches of 100
# async with client.connection() as conn:
#     await batch_update(conn, product_ids, batch_size=100)

Complete Example: E-Commerce Order Processing

START TRANSACTION WITH ISOLATION LEVEL SERIALIZABLE;

-- Create order
CREATE (order:Order {
    id: 'ord_12345',
    customer: 'Alice',
    total: 0,
    status: 'pending',
    created: timestamp()
});
SAVEPOINT order_created;

-- Add order items
UNWIND [
    {product_id: 'p1', quantity: 2, price: 50},
    {product_id: 'p2', quantity: 1, price: 100}
] AS item
MATCH (order:Order {id: 'ord_12345'})
MATCH (product:Product {id: item.product_id})
WHERE product.stock >= item.quantity
CREATE (order)-[:CONTAINS {
    quantity: item.quantity,
    price: item.price
}]->(product)
SET product.stock = product.stock - item.quantity;
SAVEPOINT items_added;

-- Check all items added
MATCH (order:Order {id: 'ord_12345'})-[c:CONTAINS]->()
WITH order, sum(c.quantity * c.price) AS calculated_total
WHERE calculated_total > 0
SET order.total = calculated_total,
    order.status = 'confirmed';

-- Create shipment
MATCH (order:Order {id: 'ord_12345'})
WHERE order.status = 'confirmed'
CREATE (:Shipment {
    order_id: order.id,
    status: 'preparing',
    created: timestamp()
});

COMMIT;

-- Verify
MATCH (order:Order {id: 'ord_12345'})
OPTIONAL MATCH (order)-[c:CONTAINS]->(product:Product)
OPTIONAL MATCH (ship:Shipment {order_id: order.id})
RETURN order.status,
       order.total,
       collect({product: product.name, quantity: c.quantity}) AS items,
       ship.status AS shipment_status;

Troubleshooting

Transaction Already Active

Problem: Error when starting transaction

Solution:

-- Rollback previous transaction
ROLLBACK;

-- Then start new one
START TRANSACTION;

No Active Transaction

Problem: COMMIT or ROLLBACK without START TRANSACTION

Solution:

-- Always start transaction first
START TRANSACTION;
-- ... operations ...
COMMIT;

Deadlock Detected

Problem: Transactions waiting on each other

Solutions:

  • Use consistent lock ordering
  • Implement retry logic with exponential backoff
  • Keep transactions short
  • Consider lower isolation level

Next Steps

Quick Reference

Transaction Commands

-- Start transaction
START TRANSACTION;
START TRANSACTION WITH ISOLATION LEVEL SERIALIZABLE;

-- Savepoints
SAVEPOINT savepoint_name;
ROLLBACK TO SAVEPOINT savepoint_name;
RELEASE SAVEPOINT savepoint_name;

-- Commit/Rollback
COMMIT;
ROLLBACK;

Isolation Levels

READ UNCOMMITTED   -- Lowest isolation, highest concurrency
READ COMMITTED     -- Default, prevents dirty reads
REPEATABLE READ    -- Prevents non-repeatable reads
SERIALIZABLE       -- Highest isolation, lowest concurrency

Best Practices

  • ✅ Keep transactions short
  • ✅ Use appropriate isolation level
  • ✅ Handle deadlocks with retry logic
  • ✅ Use savepoints for complex operations
  • ✅ Always commit or rollback
  • ❌ Don’t hold transactions during user input
  • ❌ Don’t perform expensive operations in transactions
  • ❌ Don’t ignore deadlock errors

Tutorial Complete! You now understand ACID transactions and isolation levels in Geode.

Next: Graph Algorithms Tutorial