Reed G. Law
Reed G. Law

Reputation: 3945

Make Rails controller actions atomic?

Sometime one in a long series of events within a controller action fails. For example, a credit card is processed but then an ActiveRecord query times out. Is there any way to make those calls reversible?

E.g. with this controller action:

def process_order
  cart = Cart.new(params[:cart])
  load_order
  response = credit_card.charge
  if response
    submit_order
    order.receipt = Pdf.new(render_to_string(:partial => 'receipt')
    order.receipt.pdf.generate
    order.receipt.save
    render :action => 'finished'
  else
    order.transaction = response
    @message = order.transaction.message
    order.transaction.save
    render :action => 'charge_failed'
  end
end

I would like to be able to put a block around it like so:

def process_order
  transaction
    cart = Cart.new(params[:cart])
    load_order
    response = credit_card.charge
    if response
      submit_order
      order.receipt = Pdf.new(render_to_string(:partial => 'receipt')
      order.receipt.pdf.generate
      order.receipt.save
      render :action => 'finished'
    else
      order.transaction = response
      @message = order.transaction.message
      order.transaction.save
      render :action => 'charge_failed'
    end
  rollback
    credit_card.cancel_charge
    ...
  end
end

This is just a contrived example and I'm not really sure how it would work in practice. What typically happens is we get an exception like ActiveRecord::StatementInvalid: : execution expired for the line with submit_order and then we have to go and manually run the rest of the lines that should have run.

Upvotes: 1

Views: 1487

Answers (3)

Patrick Hillert
Patrick Hillert

Reputation: 2439

I'm a bit late but I think you should use save! instead of save. save just returns false if something fails within your model but save! raises an exception and your ActiveRecord::Base.transaction do block rolls back your changes correctly...

For example:

def process_order
  ActiveRecord::Base.transaction do
    begin
      cart = Cart.new(params[:cart])
      load_order
      response = credit_card.charge

      if response
        submit_order
        order.receipt = Pdf.new(render_to_string(:partial => 'receipt')
        order.receipt.pdf.generate
        order.receipt.save!

        render :action => 'finished'
      else
        order.transaction = response
        @message = order.transaction.message
        order.transaction.save!

        render :action => 'charge_failed'
      end
    rescue
      # Exception raised ... ROLLBACK
      raise ActiveRecord::Rollback
    end
end

Upvotes: 1

Kelvin
Kelvin

Reputation: 20912

Here's a generic solution.

class Transactable
  def initialize(&block)
    raise LocalJumpError unless block_given?
    @block = block
  end
  def on_rollback(&block)
    raise LocalJumpError unless block_given?
    @rollback = block
    self
  end
  def call
    @block.call
  end
  def rollback
    @rollback.call if @rollback
  end
end

class Transaction
  def initialize(tasks)
    tasks = Array(tasks)
    tasks.each do |t|
      Transactable === t or raise TypeError
    end
    @tasks = tasks
  end
  def run
    finished_tasks = []
    begin
      @tasks.each do |t|
        t.call
        finished_tasks << t
      end
    rescue => err
      finished_tasks.each do |t|
        t.rollback
      end
      raise err
    end
  end
end

if __FILE__ == $0
  Transaction.new([
    Transactable.new { puts "1: call" }.on_rollback { puts "1: rollback" },
    Transactable.new { puts "2: call" }.on_rollback { puts "2: rollback" },
    Transactable.new { puts "3: call"; raise "fail!" }.on_rollback { puts "3: rollback" },
  ]).run
end

Note that it doesn't:

  • handle errors in the rollback block
  • call the rollback for the failed task, but that's easy to adjust

Upvotes: 3

iblue
iblue

Reputation: 30434

Just wrap it in

cart.transaction do
  # ...
end

to use transactions. For details see http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html

Upvotes: 1

Related Questions