Title Text

ACID's EXTASY

Benjamin Roth

@apneadiving

🐮

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...

See waterfall gem

Questions?

Text

Made with Slides.com