Transactions

ACID transactions with MVCC snapshot isolation. Multi-collection operations with automatic commit/rollback.

ACID Guarantees

Every ALOS DB transaction provides full ACID guarantees:

A

Atomicity

All operations commit together or none do. No partial writes.

C

Consistency

Database moves from one valid state to another. Indexes stay in sync.

I

Isolation

MVCC snapshot isolation. Transactions don’t see each other’s uncommitted data.

D

Durability

Committed data survives crashes when --sync-mode sync is enabled.

Closure Pattern (Recommended)

The closure pattern automatically manages commit and rollback:

go
err := db.Transaction(func(tx alosdbclient.TransactionInterface) error {
    users := tx.Collection("users")

    // Insert a user
    id, err := users.InsertOne(alosdbclient.Document{
        "name":  "Alice",
        "email": "alice@example.com",
    })
    if err != nil {
        return err  // triggers rollback
    }

    // Update the user
    err = users.UpdateOne(
        alosdbclient.Document{"_id": id},
        alosdbclient.Document{"$set": alosdbclient.Document{"verified": true}},
    )
    if err != nil {
        return err  // triggers rollback
    }

    return nil  // triggers commit
})
if err != nil {
    log.Printf("Transaction failed: %v", err)
}

Return nil → auto-commit. Return error → auto-rollback. Panic → auto-rollback. You never need to call Commit or Rollback explicitly.

Manual Transactions

For cases where you need explicit control over the transaction lifecycle:

go
tx := db.BeginTransaction()

users := tx.Collection("users")

_, err := users.InsertOne(alosdbclient.Document{
    "name": "Bob",
    "age":  25,
})
if err != nil {
    tx.Rollback()
    log.Fatal(err)
}

// Commit all changes
if err := tx.Commit(); err != nil {
    log.Fatal("commit failed:", err)
}

// Get the transaction ID for logging
fmt.Println("Committed TX:", tx.GetID())

With manual transactions, you must call either Commit() or Rollback(). Forgetting to do so will leave the transaction open. Prefer the closure pattern when possible.

Cross-Collection Transactions

A single transaction can span multiple collections. All operations commit or rollback together:

go
err := db.Transaction(func(tx alosdbclient.TransactionInterface) error {
    users := tx.Collection("users")
    accounts := tx.Collection("accounts")
    auditLog := tx.Collection("audit_log")

    // Create user
    userID, err := users.InsertOne(alosdbclient.Document{
        "name":  "Alice",
        "email": "alice@example.com",
    })
    if err != nil {
        return err
    }

    // Create account for user
    _, err = accounts.InsertOne(alosdbclient.Document{
        "user_id": userID,
        "balance": 0.0,
        "status":  "active",
    })
    if err != nil {
        return err
    }

    // Write audit log entry
    _, err = auditLog.InsertOne(alosdbclient.Document{
        "action":  "user_created",
        "user_id": userID,
    })
    return err
})

If any operation fails, all three collections are rolled back to their state before the transaction began.

Read-Modify-Write Pattern

A common pattern: read a document, modify it, and write it back — all atomically. With ALOS DB's interactive transactions, FindOne inside a transaction reads from the transaction's snapshot, and concurrent modifications are detected at commit time.

go
// Transfer money between accounts
err := db.Transaction(func(tx alosdbclient.TransactionInterface) error {
    accounts := tx.Collection("accounts")

    // Read source account (from transaction snapshot)
    src, err := accounts.FindOne(alosdbclient.Document{"_id": sourceID})
    if err != nil {
        return fmt.Errorf("source not found: %w", err)
    }

    // Check balance
    balance := src["balance"].(float64)
    if balance < amount {
        return fmt.Errorf("insufficient funds: %.2f < %.2f", balance, amount)
    }

    // Debit source
    err = accounts.UpdateOne(
        alosdbclient.Document{"_id": sourceID},
        alosdbclient.Document{"$set": alosdbclient.Document{
            "balance": balance - amount,
        }},
    )
    if err != nil {
        return err
    }

    // Credit destination
    dst, err := accounts.FindOne(alosdbclient.Document{"_id": destID})
    if err != nil {
        return fmt.Errorf("destination not found: %w", err)
    }
    dstBalance := dst["balance"].(float64)

    return accounts.UpdateOne(
        alosdbclient.Document{"_id": destID},
        alosdbclient.Document{"$set": alosdbclient.Document{
            "balance": dstBalance + amount,
        }},
    )
})

Conditional Updates with $gte

For extra safety on balance checks, put the condition directly in the UpdateOne filter. The server re-checks the filter at commit time under the document lock, so even if another transaction changes the balance between your read and commit, the update will abort safely:

go
// Atomic conditional debit — aborts if balance is insufficient at commit time
err := db.Transaction(func(tx alosdbclient.TransactionInterface) error {
    users := tx.Collection("users")

    // The $gte filter is re-checked at commit under the document lock
    err := users.UpdateOne(
        alosdbclient.Document{
            "email":   "alice@example.com",
            "balance": alosdbclient.Document{"$gte": alosdbclient.Int64(50)},
        },
        alosdbclient.Document{"$sub": alosdbclient.Document{"balance": alosdbclient.Int64(50)}},
    )
    if err != nil {
        // "document not found" means the filter didn't match (insufficient balance)
        return fmt.Errorf("insufficient balance")
    }
    return nil
})

Best of both worlds: The FindOne gives you the current value for business logic, and the $gte in the UpdateOne filter guarantees the commit only succeeds if the balance is still sufficient. This prevents race conditions even under high concurrency.

Error Handling

Errors inside a transaction trigger automatic rollback. Handle them cleanly:

go
err := db.Transaction(func(tx alosdbclient.TransactionInterface) error {
    users := tx.Collection("users")

    // This might fail if user doesn't exist
    user, err := users.FindOne(alosdbclient.Document{"email": email})
    if err != nil {
        return fmt.Errorf("user lookup: %w", err)
    }

    // Business logic check
    if user["status"] == "suspended" {
        return fmt.Errorf("user %s is suspended", email)
    }

    return nil
})

if err != nil {
    // Transaction was rolled back — handle the error
    log.Printf("Transaction failed: %v\n", err)
} else {
    // Transaction was committed successfully
    log.Println("Transaction committed")
}

MVCC Isolation

ALOS DB uses Multi-Version Concurrency Control (MVCC) for transaction isolation. When a transaction begins, it takes a snapshot of the database:

  • Reads within the transaction see a consistent snapshot from the transaction start time
  • Writes from other transactions are invisible until they commit
  • Your writes are invisible to other transactions until you commit
  • The MVCC timestamp is sub-2ns (~590M ops/sec), so snapshot creation is essentially free
text
Time ──────────────────────────────────────────►

TX-1:  BEGIN ─── Read(A) ─── Write(A) ─── COMMIT
                    │                         │
                    │    TX-2 writes A here    │
                    │    (TX-1 doesn't see it) │
                    │                         │
TX-2:       BEGIN ──── Write(A) ── COMMIT     │
                                              │
                    TX-1 sees its own snapshot ─┘

MVCC means readers never block writers and writers never block readers. Only write-write conflicts on the same document require coordination.

Best Practices

  • Prefer the closure patterndb.Transaction(fn) handles commit/rollback automatically and is harder to misuse
  • Keep transactions short — long-running transactions hold snapshots and delay garbage collection
  • Don’t do I/O inside transactions — HTTP calls, file writes, etc. should happen outside the transaction closure
  • Return errors early — trigger rollback as soon as something fails rather than continuing with partial state
  • Use sync mode for critical data--sync-mode sync ensures committed transactions survive crashes
  • One transaction at a time per goroutine — don’t nest transactions or share transaction handles across goroutines
  • Use conditional filters for balance checks — put $gte in your UpdateOne filter so the server re-checks the condition at commit time under the document lock. This prevents race conditions even if MVCC is disabled.
ESC