1.4.1

Transactions

Preview ecosystem guide: Grant is not part of the Amber 2.0.0-beta.2 core web-app release gate. Its package version, API, and platform support may change independently. Do not add a personal fork as a default dependency.

Proper transaction management is essential for data integrity. Grant provides comprehensive support for transactions, isolation levels, and locking strategies.

Basic Transactions

Grant::Base.transaction do
  user = User.find!(1)
  user.balance -= 100
  user.save!

  transfer = Transfer.create!(
    user_id: user.id,
    amount: -100
  )

  # Automatic rollback on exception
  raise "Insufficient funds" if user.balance < 0
end

Transaction Methods

# Block syntax
Grant::Base.transaction do
  # All operations in one transaction
  User.create!(name: "Alice")
  User.create!(name: "Bob")
end

# With explicit rollback
Grant::Base.transaction do |tx|
  user = User.create!(name: "Alice")

  if some_condition_fails
    raise DB::Rollback.new("Condition failed")
  end
end

Nested Transactions with Savepoints

Grant::Base.transaction do
  order = Order.create!(customer_id: 1, total: 0)

  items.each do |item_data|
    Grant::Base.transaction do  # Savepoint
      item = OrderItem.create!(
        order_id: order.id,
        product_id: item_data[:product_id],
        quantity: item_data[:quantity]
      )

      product = Product.find!(item_data[:product_id])
      product.stock -= item_data[:quantity]

      # Rollback just this item if out of stock
      raise "Out of stock" if product.stock < 0

      product.save!
      order.total += item.subtotal
    end
  rescue
    # Skip item but continue with order
    Log.warn { "Skipping item #{item_data[:id]}" }
  end

  order.save!
end

Isolation Levels

# Available levels
IsolationLevel::ReadUncommitted
IsolationLevel::ReadCommitted
IsolationLevel::RepeatableRead
IsolationLevel::Serializable

# Serializable for financial operations
Grant::Base.transaction(isolation: :serializable) do
  account1 = Account.find!(1)
  account2 = Account.find!(2)

  account1.balance -= 100
  account2.balance += 100

  account1.save!
  account2.save!
end

# Read committed for reports
Grant::Base.transaction(isolation: :read_committed) do
  generate_report
end

Pessimistic Locking

Lock rows to prevent concurrent modifications.

Row-Level Locking

Grant::Base.transaction do
  # Lock account for update
  account = Account.find!(1)
  account.lock!  # FOR UPDATE

  # No other transaction can modify this account
  account.balance -= 100
  account.save!
end

# Lock with custom mode
Grant::Base.transaction do
  account = Account.lock!(:share)  # FOR SHARE
  # Read but prevent updates
end

with_lock Helper

account = Account.find!(1)

account.with_lock do |locked_account|
  locked_account.balance -= 100
  locked_account.save!
end

Lock Multiple Rows

Grant::Base.transaction do
  accounts = Account.where(user_id: 1).lock
  accounts.each do |account|
    account.process_fees
  end
end

Optimistic Locking

Use a version column to detect concurrent modifications.

class Product < Grant::Base
  include Grant::Locking::Optimistic

  column id : Int64, primary: true
  column name : String
  column price : Float64
  column lock_version : Int32 = 0
end

# Automatic version checking
product = Product.find!(1)
product.price = 29.99
product.save!  # Increments lock_version

# Concurrent update detection
product1 = Product.find!(1)
product2 = Product.find!(1)

product1.price = 19.99
product1.save!  # Works

product2.price = 24.99
product2.save!  # Raises Grant::StaleRecordError

Handling Conflicts

def update_with_retry(product, max_retries = 3)
  retry_count = 0

  loop do
    begin
      yield product
      product.save!
      break
    rescue Grant::StaleRecordError
      retry_count += 1
      raise if retry_count >= max_retries

      product.reload
      Log.info { "Retrying update (attempt #{retry_count})" }
    end
  end
end

update_with_retry(product) do |p|
  p.stock -= 1
end

Deadlock Prevention

Ordered Locking

Always acquire locks in the same order to prevent deadlocks.

def transfer_funds(from_id, to_id, amount)
  # Sort IDs to ensure consistent lock order
  ids = [from_id, to_id].sort

  Grant::Base.transaction do
    accounts = ids.map { |id| Account.find_and_lock!(id) }
    from = accounts.find { |a| a.id == from_id }.not_nil!
    to = accounts.find { |a| a.id == to_id }.not_nil!

    from.balance -= amount
    to.balance += amount

    from.save!
    to.save!
  end
end

Lock Timeouts

Grant::Base.transaction do
  Grant.connection.exec("SET LOCAL lock_timeout = '5s'")

  begin
    account = Account.find_and_lock!(1)
    account.process!
  rescue ex : DB::Error
    if ex.message.includes?("lock timeout")
      Log.warn { "Lock timeout, retrying..." }
    end
    raise ex
  end
end

Transaction Callbacks

class Order < Grant::Base
  after_commit :send_confirmation, on: :create
  after_commit :update_inventory, on: :update
  after_rollback :log_failure

  private def send_confirmation
    # Safe - transaction committed
    OrderMailer.confirmation(self).deliver_later
  end

  private def update_inventory
    InventoryService.sync(self)
  end

  private def log_failure
    Log.error { "Order #{id} failed to save" }
  end
end

Best Practices

1. Keep Transactions Short

# Good: Short transaction
Grant::Base.transaction do
  user.update!(status: "active")
end

# Bad: Long transaction
Grant::Base.transaction do
  users = User.all.to_a
  users.each do |user|
    user.process_complex_logic  # Time-consuming
    user.save!
  end
end

2. Use Appropriate Isolation

# Serializable for critical financial operations
Grant::Base.transaction(isolation: :serializable) do
  transfer_funds(from, to, amount)
end

# Read committed for reports (better performance)
Grant::Base.transaction(isolation: :read_committed) do
  generate_report
end

3. Handle Failures Gracefully

def process_order(order)
  Grant::Base.transaction do
    order.process!
    Payment.charge!(order)
    Inventory.decrement!(order)
  end
rescue Grant::RecordInvalid => e
  Log.error { "Validation failed: #{e.message}" }
  order.update!(status: "failed")
rescue => e
  Log.error { "Order processing failed: #{e.message}" }
  raise
end

4. Test Transaction Behavior

describe "Transfer funds" do
  it "rolls back on failure" do
    account1 = Account.create!(balance: 100)
    account2 = Account.create!(balance: 50)

    expect_raises(Exception) do
      Grant::Base.transaction do
        account1.balance -= 200  # More than available
        account2.balance += 200
        account1.save!
        account2.save!
        raise "Insufficient funds"
      end
    end

    # Both accounts unchanged
    account1.reload.balance.should eq(100)
    account2.reload.balance.should eq(50)
  end
end