Hao's Thoughts

Ruby, Rails, Objective-C and everything else

Gotcha: ActiveRecord::Base.transaction Stops Working Within Callbacks

If you are not familiar with ActiveRecord::Base.transaction, i highly recommend you go through the document.

As you can see,

Exceptions will force a ROLLBACK that returns the database to the state before the transaction began.

that means the following code should rollback the transaction if any of the deposit! fails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# transaction.rb
class Transaction < ActiveRecord::Base
  after_create :transfer_credits

  attr_accessible :credits

  belongs_to :send_account, :class_name => "Account"
  belongs_to :recv_account, :class_name => "Account"

  private
  def transfer_credits
    begin
      ActiveRecord::Base.transaction do
          send_account.deposit!(-credits)
          recv_account.deposit!(credits)
      end
    rescue => e
      Rails.logger.info e.message
    end
  end
end

# transaction_spec.rb
context "exception raised" do
  before do
    @send_account = create :account, :credits => 1000

    @transaction = Transaction.new
    @transaction.send_account = @send_account
    @transaction.credits = 1

    recv_account = double(:account).as_null_object
    @transaction.stub(:recv_account) {recv_account}
    recv_account.should_receive(:deposit!).and_raise(StandardError.new("Transfer failed!"))
  end

  it "should not transfer the credits" do
    @transaction.save

    @send_account.reload

    @send_account.credits.should == 1000
  end
end

But the result is the spec fails and says

1
2
3
4
5
6
7
8
9
10
Failures:

  1) Transaction status exception raised should not transfer the credits
     Failure/Error: @send_account.credits.should == 1000
       expected: 1000
            got: 999 (using ==)
     # ./spec/models/transaction_spec.rb:185:in `block (4 levels) in <top (required)>'

Finished in 1.73 seconds
1 example, 1 failure

It did deduct the credits from the send_account by 1 while i was expecting it should do nothing to it. What’s going wrong, the doc says it will rollback the transaction, then I tried to test if calling transfer_credits explicitly is going to work. And yes, it works. Here’s the revised version:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# transaction.rb
class Transaction < ActiveRecord::Base
  attr_accessible :credits

  belongs_to :send_account, :class_name => "Account"
  belongs_to :recv_account, :class_name => "Account"

  def transfer_credits
    begin
      ActiveRecord::Base.transaction do
          send_account.deposit!(-credits)
          recv_account.deposit!(credits)
      end
    rescue => e
      Rails.logger.info e.message
    end
  end
end

# transaction_spec.rb
describe "#transfer_credits" do
  before do
    @send_account = create :account, :credits => 1000

    @transaction = Transaction.new
    @transaction.send_account = @send_account
    @transaction.credits = 1

    recv_account = double(:account).as_null_object
    @transaction.stub(:recv_account) {recv_account}
    recv_account.should_receive(:deposit!).and_raise(StandardError.new("Transfer failed!"))
  end

  it "should not transfer the credits" do
    @transaction.transfer_credits

    @send_account.reload

    @send_account.credits.should == 1000
  end
end

GOTCHA: DO NOT place ATOMIC operations within callbacks, like before_*, after_*

Comments