更新情報

こんにちは、トランスネットの泉です。

Ruby on Railsについてのコラムenjoy Railsway、第2回は「ActiveRecord::Base.transaction ロールバック編」 をお送りします。

複数のモデルを一度に更新するような処理をおこなう場合、原子性を担保するためにトランザクションを考慮した実装となるはずです。

atomic

Ruby on Railsでの開発では、 ActiveRecord::Base.transaction を利用することになります。

ロールバックされない?トランザクション

さてこのtransactionですが、使い方を間違えてしまうとうまく機能しません。

ActiveRecord::Baseを継承したクラス、FooとBarがあるとします。
次の例のfoo、barはこれらのクラスのインスタンスです。
barのバリデーション結果がfalseとなっているため保存に失敗します。

そのため、同じトランザクション内でsaveしたfooの状態も巻き戻るはずです。

puts foo.updated_at # => 2014-10-06 12:00:00 +0900
puts bar.updated_at # => 2014-10-06 12:00:00 +0900
puts foo.valid? # => true
puts bar.valid? # => false
ActiveRecord::Base.transaction do
foo.save
bar.save
end
puts foo.updated_at # => 2014-10-07 09:00:00 +0900
puts bar.updated_at # => 2014-10-06 12:00:00 +0900

foo.updated_atの値が変更されたようです。
…fooが更新されてしまいました。

transactionがロールバックされる条件

transactionがロールバックされる条件は、「ブロック内で例外が発生する」ことです。

saveメソッドは、保存に失敗したときにfalseを返しますが、例外を発生させません。
そのため、transactionをロールバックさせるためには、保存に失敗したときに例外を発生させるsave!メソッドを利用します。

saveとsave!

Railsのドキュメントから、二つのメソッドの挙動の違いを引用します。

save

  • バリデーションやコールバックで保存がキャンセルされると false を返す

 

save!

  • バリデーションで保存がキャンセルされると ActiveRecord::RecordInvalid が発生する
  • コールバックで保存がキャンセルされると ActiveRecord::RecordNotSaved が発生する

例を一目見ただけでお気付きの方も多いと思いますが、barの保存にはsaveメソッドを使っていたため、例外が発生せず、ロールバックも発生しなかったのです。

このように!ひとつで大きな差が出てしまうため、私がコードレビューを実施する際は入念にチェックしています。
また、save!しても差し支えないような状況では、saveではなくsave!を普段から積極的に使うように働きかけています。
(そもそも、モデルの保存に失敗したことをケアしなくていいような処理は稀なはずです…)

ActiveRecord::Rollback

ちなみに、自分で例外を発生させてロールバックさせるような場合は、ActiveRecord::Rollbackを使うことができます。

raise ActiveRecord::Rollback

この例外はtransactionの外側では捕捉されません。
この点については後ほどご紹介します。

puts foo.updated_at # => 2014-10-06 12:00:00 +0900
puts bar.updated_at # => 2014-10-06 12:00:00 +0900
puts bar.valid? # => false
begin
ActiveRecord::Base.transactiondo
foo.save!
raise ActiveRecord::Rollback unless bar.save # 保存に失敗すると例外発生
end
rescue ActiveRecord::Rollback
# この箇所が実行されることは無い
end
puts foo.updated_at # => 2014-10-06 12:00:00 +0900
puts bar.updated_at # => 2014-10-06 12:00:00 +0900

foo.updated_atの値が変更されていません。
…fooは正しくロールバックされたようです。

Reading Rails!

ところで、この挙動について書籍などでもあまり詳しく説明されないことがあるようです。
幸い、ActiveRecordのソースコードは誰でも読むことができます。
折角ですので、実際のところActiveRecord内部ではどのような仕組みになっているのか確認してみることにします。

※ここではGitHubのリポジトリタグv4.1.6時点のソースコードを参照しています。

rails / activerecord / lib / active_record / transactions.rb

206       def transaction(options = {}, &block)
207         # See the ConnectionAdapters::DatabaseStatements#transaction API docs.
208         connection.transaction(options, &block)
209       end

ConnectionAdapters::DatabaseStatements#transactionを見てみます。

rails / activerecord / lib / active_record / connection_adapters / abstract / database_statements.rb

191       def transaction(options = {})
192         options.assert_valid_keys :requires_new, :joinable, :isolation
193
194         if !options[:requires_new] && current_transaction.joinable?
195           if options[:isolation]
196             raise ActiveRecord::TransactionIsolationError, “cannot set isolation when joining a transaction”
197           end
198
199           yield
200         else
201           within_new_transaction(options) { yield }
202         end
203       rescue ActiveRecord::Rollback
204         # rollbacks are silently swallowed
205       end

within_new_transactionメソッド内で処理がおこなわれそうです。
また、 rescue ActiveRecord::Rollback という記述が確認できます。
ActiveRecord::Rollbackはここで拾われるため、transactionの外側のrescueで捕捉されることはないことを確認することができました。

rails / activerecord / lib / active_record / connection_adapters / abstract / database_statements.rb

207       def within_new_transaction(options = {}) #:nodoc:
208         transaction = begin_transaction(options)
209         yield
210       rescue Exception => error
211         rollback_transaction if transaction
212         raise
213       ensure
214         begin
215           commit_transaction unless error
216         rescue Exception
217           rollback_transaction
218           raise
219         end
220       end

within_new_transactionメソッド内でトランザクションの開始処理の後、ブロック内の処理がおこなわれていることを確認できます。
注目点は、rollback_transactionメソッドの呼び出しです。このメソッドは、このwithin_new_transactionメソッド内で例外をrescueした場合にのみ呼び出されています。
つまり、「transaction内では例外が発生したときのみ、ロールバックが発生する」ことになります。
これでActiveRecord::Base.transactionの挙動を確認することができました。

まとめ

ActiveRecord::Base.transactionでのロールバック処理についてご紹介しました。
「saveではうまくいかなかったが、save!したらうまくいった!」といった表面的なところに留まらずに、「何故そうなるか」をご理解いただけましたでしょうか。
こうして実装を確認して頭に入れておくことで、「transactionブロックを書いたのに、ロールバックされない…!」というような悩みから解放されると思います:-)

それでは今日はこの辺で。次回をお楽しみに♪