Transaction Safety in Familia
Overview
Familia uses Redis transactions (MULTI/EXEC) for atomic operations. However, Redis transactions have a fundamental limitation: commands within a transaction return Redis::Future objects instead of actual values. These Future objects cannot be inspected until the transaction completes.
Core Rules
1. No Save Operations Inside Transactions
Rule: The following methods cannot be called within a transaction context:
savesave!save_if_not_exists!create!(callssave_if_not_exists!internally)
Rationale: These methods need to read current state for validation (checking existence, validating unique constraints), which would return uninspectable Redis::Future objects inside transactions.
Error: Calling these methods inside a transaction raises Familia::OperationModeError
Correct Pattern:
# ✅ GOOD: Save before transaction
customer = Customer.new(email: 'test@example.com')
customer.save # Validates unique constraints here
customer.transaction do
# Perform other atomic operations
customer.increment(:login_count)
customer.hset(:last_login, Familia.now.to_i)
end
# ❌ BAD: Save inside transaction
Customer.transaction do
customer = Customer.new(email: 'test@example.com')
customer.save # Raises Familia::OperationModeError
end
2. Unique Index Guards Run Before Transactions
Rule: Unique constraint validation (guard_unique_indexes!) and the server-side claim (claim_unique_<index>!) must execute outside transaction context.
Rationale: Guards need to read current index state to check for duplicates, and the claim's CAS verdict must be inspectable. Inside a transaction, both return Redis::Future objects that cannot be inspected.
Implementation: save — and the partial writers commit_fields, save_fields, and multi_field_update — automatically guard and claim BEFORE starting their internal transaction, then re-affirm the claim inside it.
Scope note: save_fields and multi_field_update guard and claim only the indexes covering the fields being written. commit_fields writes the full hash, so it guards and claims EVERY class-level unique index — one CAS round-trip per index — even when the indexed value is unchanged (an unchanged value resolves as :owned and succeeds). If you are updating a small set of non-indexed fields on a class with several unique indexes, save_fields/multi_field_update avoid those extra round-trips.
3. Create with Success Callback
Pattern: Use the block form of create! for additional operations:
Customer.create!(email: 'new@example.com') do |customer|
# This block only runs if creation succeeded
customer.add_to_premium_group
NotificationService.send_welcome_email(customer)
end
4. Transaction-Safe vs Unsafe Methods
Transaction-Safe (can be called inside transactions):
- Write-only operations:
hmset,hdel,expire,hset - Predictable operations:
increment,decrement - Collection operations:
add,remove,push - Instance-scoped index operations:
add_to_company_badge_index(validation skipped)
Transaction-Unsafe (must be called outside transactions):
- Read operations that need immediate values:
exists?,hget,size - Validation operations:
guard_unique_indexes!,claim_unique_<index>! - Save operations:
save,save_if_not_exists!,create! - Fast writers (
field!) on fields backing a class-level index — raiseFamilia::IndexedFieldFastWriteErrorinside a transaction or pipeline, since the index claim cannot run there
5. Handling Nested Transactions
Behavior: Familia uses reentrant transactions (see
TransactionCore.execute_normal_transaction). If you're already in a
transaction, a nested transaction call does not open a new MULTI/EXEC — it
yields the outer transaction's connection, so the nested block's commands are
queued into the outer MULTI and commit (or fail) with the outer EXEC.
Nesting itself never raises. The Familia::OperationModeError cases described
elsewhere in this document come from what runs inside a transaction (save,
create!, partial writes on unique-indexed fields) or from connection handlers
that do not support transactions — not from nested transaction calls.
Customer.transaction do |conn|
# Outer transaction
customer.increment(:counter)
customer.transaction do |inner_conn|
# Same connection as outer - no new MULTI/EXEC
customer.decrement(:other_counter)
end
end
Examples
Example 1: Correct Usage with Unique Constraints
# Create with unique email constraint
begin
customer = Customer.create!(email: 'user@example.com')
puts "Created customer: #{customer.email}"
rescue Familia::RecordExistsError => e
puts "Customer already exists: #{e.}"
end
Example 2: Atomic Multi-Object Updates
# Save all objects first
order = Order.new(order_id: 'ORD-123')
order.save
inventory = Inventory.find('ITEM-456')
# Then use transaction for atomic updates
Order.transaction do
order.hset(:status, 'confirmed')
inventory.decrement(:quantity, 1)
order.add_to_daily_orders
end
Example 3: Bulk Creation Pattern
# Claim each unique value outside the transaction. The claim is a
# server-side compare-and-set: the loser of a concurrent race raises
# Familia::RecordExistsError here, before anything is written.
customers = emails.map do |email|
customer = Customer.new(email: email)
customer.claim_unique_email_lookup!
customer
end
# Then save atomically. The in-transaction index write is legal only
# because it re-affirms the claim taken above (see ADR-0002); without
# a claim, add_to_class_* raises Familia::OperationModeError.
Customer.transaction do
customers.each do |customer|
# Direct write operations only - no save!
customer.hmset(customer.to_h_for_storage)
customer.add_to_class_email_lookup
end
end
Example 4: Instance-Scoped Bulk Indexing
# Instance-scoped indexes can be added within transactions
# Uniqueness validation is automatically skipped inside transactions
Company.transaction do
employees.each do |employee|
# Safe: validation skipped, direct index write only
employee.add_to_company_badge_index(company)
end
end
Common Pitfalls
Pitfall 1: Checking Existence in Transaction
# ❌ WRONG
Customer.transaction do
unless customer.exists? # Returns Redis::Future, always truthy!
customer.save
end
end
# ✅ CORRECT
unless customer.exists?
customer.save
end
Pitfall 2: Creating in Transaction
# ❌ WRONG
Customer.transaction do
Customer.create!(email: 'test@example.com') # Raises OperationModeError
end
# ✅ CORRECT
customer = Customer.create!(email: 'test@example.com')
customer.transaction do
# Additional operations
end
Migration Guide
If you have code that saves within transactions:
- Move save operations outside the transaction
- Use the transaction for atomic updates only
- Validate constraints before entering the transaction
- Use write-only operations inside the transaction