ACID's EXTASY
Benjamin Roth
🐮
ACID is great
Don't do drugs
ACID
Fighting with bugs?
Tired of data inconsistency?
No Bad Trip
Coding Under Bad Influence
CALLBACKS
Very Bad Trip
Callback Rabbit Hole?
Taking Step Back 1/2
SERVER
Controller
does stuff with models
Request
Response
Taking Step Back 2/2
SERVER
Controller
Request
Response
Business Logic
Web is just a channel
Cook!
Models are just ingredients in a Recipe
Service Object
Logic self contained
handles both happy and unhappy paths
Happy path is a lie, we have to deal with failure
Service Object Principles
1- do your Stuff
2- on failure, leave a predictable state
Predictable State
On failure:
- reset everything to ensure data consistency
- report errors
Nested Service Objects
1
2
3
4
5
6
7
If any nested service fails, the whole should be rolled back
Database Transaction
From BEGIN to COMMIT or ROLLBACK
Database Transaction Code
class InviteUserToBeta < Struct.new(:email)
def call
ApplicationRecord.transaction do
# Data Integrity ensured there!
end
rescue StandardError => e
# if you are here, transaction has been rolled back
end
private
# methods go there
end
AC ⚡
Database transaction provides with:
- Atomicity: the transaction is performed completely or not at all
- Consistency: Integrity of the data ensured before and after commit
Rollback => clean state in case of failure
Who cares?
Without Atomicity:
Need for write scripts to clean up after ourselves in case of failure.
Without Consistency:
Scripts again to check over whole application's data and check for integrity.
Nesting
Services inside Services
Nested Transactions
Integrity kept
Nested Transactions
class User::Charge < Struct.new(:user, :amount)
def call
ApplicationRecord.transaction(requires_new: true) do
transaction = User::CreateTransaction.new(user, amount).call
User::CreateInvoice.new(transaction).call
end
end
end
class User::CreateTransaction < Struct.new(:user, :amount)
def call
ApplicationRecord.transaction(requires_new: true) do
# logic lives here
end
end
end
class User::CreateInvoice < Struct.new(:transaction)
def call
ApplicationRecord.transaction(requires_new: true) do
# logic lives here
end
end
end
Transactions hiccup
If everything is rolled back on error,
we cannot persist things like Error logs in DB.
Context switch
listener = ErrorLogsListener.new
begin
Wisper.subscribe(listener) do
User::Invite.new(email).call
end
rescue
listener.trigger_commands
end
class ErrorLogsListener
def initialize
@command_blocks = []
end
def add_log(block)
@command_blocks.push(block)
end
def trigger_commands
@command_blocks.each &:call
end
end
class User::Invite < Struct.new(:email)
include Wisper::Publisher
def call
ApplicationRecord.transaction do
# logic lives here
end
rescue StandardError => e
broadcast_error; raise e
end
def broadcast_error
broadcast(:add_log, ->{ Log.create(...)})
end
end
Background Jobs
Delayed Job stores jobs in database.
Jobs are not executed until the whole transaction is committed.
- Isolation: changes made by a transaction are invisible to the other until the commit.
Without Isolation?
We'd have to write our own locking systems
(I prefer not to imagine)
Redis Backed Jobs
So yes its outside of any database transaction.
If you enqueue an object but the transaction is rolled back, the job will fail because it wont find the non existing record.
If you enqueue but the job is taken from the queue before the transaction is committed, will error again (remember isolation?).
Safe enqueuing?
listener = JobsListener.new
begin
Wisper.subscribe(listener) do
User::Invite.new(email).call
end
listener.trigger_commands
rescue
# ...
end
class JobsListener
def initialize
@command_blocks = []
end
def add_job(block)
@command_blocks.push(block)
end
def trigger_commands
@command_blocks.each &:call
end
end
class User::Invite < Struct.new(:email)
include Wisper::Publisher
def call
ApplicationRecord.transaction do
# logic lives here
send_email_later
end
end
def send_email_later
broadcast(:add_job, ->{
Notifier.invite(user.id).deliver_later
})
end
end
External API Calls
No transactions but Compensating Requests.
Basically, prepare for each successful API request a compensating one to undo if need be
Caitie McCaffrey - @caitie - Video Presentation
Undoing Api Call
class ApiListener
def initialize
@command_blocks = []
end
def compensate(block)
@command_blocks.push(block)
end
def trigger_commands
@command_blocks.each &:call
end
end
class Services::BookCar < Struct.new(:booking)
include Wisper::Publisher
def call
send_booking_request
enqueue_compensation_request
# here we enqueue compensation if only
# booking request succeeded
end
def enqueue_compensation_request
broadcast(:compensate, ->{
UnbookCarWorker.perform_later(booking)
})
end
end
API Principles 1/2
you need an idempotent API
like Stripe:
every request comes with a unique identifier
- you cannot create the same entry twice
- second "Create" call just returns the existing entry
API Principles 2/2
Delay proof API
Request 1: Create Booking
Request 2: Cancel Booking
Time
R1
R1
R2
R2
Your app
Api
"Create" received after "Cancel" must not create...
Recap: Handle Errors!
Text
Exceptions VS Flow Control...
Questions?
Text