Transactions
Supported web path: Amber CLI
2.0.6includes Grant in every generated web application and pins the reviewed V2 commit. Preserve that pin while following this beta.
Where the examples go
Transaction and locking expressions run from the controller, job, service, or
spec that owns the multi-record operation. Optimistic-locking columns and
transaction callback declarations belong inside the matching Grant model under
src/models/. Shared financial or inventory workflows should live in a service
under src/services/ with focused specs. Blocks on this page use those
destinations unless a closer comment identifies a different role.
Use a transaction when several writes must commit or roll back together. Grant also exposes isolation and locking controls for workflows that coordinate concurrent database changes.
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