Railsのテストで悲観的ロックを再現する方法(翻訳)
Railsアプリでデータベースのロックをテストしたいなと思って探していたらよいブログポスト(英語)が見つかったので自分の理解も兼ねてざっと翻訳。
ポイントは、テストの中でサブプロセスをforkし、ロックが期待される状況を再現しているところ。
コードにすると少し複雑だが、ブレイクポイントでfind
の実行タイミングを制御することでうまく再現している。
アプリケーション的に大事なロジックなどはこれでテストするとよさそう。
以下翻訳。
並列処理を正しくやるのはなかなか難しい。それをテストするのも同じように難しい。以下のシンプルなコントローラーの例から始める。
app/controllers/counters_controller.rb
class CountersController < ApplicationController def increment counter = Counter.find(params[:id]) counter.increment!(:value) render text: counter.value end end
うまくいっているように見える?でも find
と increment!
の間に競合状態がある。
訳注:ここでいう競合状態(race condition)とは、findのタイミングによっては必ずしもincrementされるわけではないということ。後述のテストの例がわかりやすい。
修正する前に、テストを書いて失敗させてみることにする。そのテストには、fork_break
というgemを使う。そのgemを使えば、ブレイクポイントを使っている親プロセスとサブプロセスを同期させることができる。
ここで、テストがデータベーストランザクション内部で実行されていないようにしておく必要がある。
spec/spec_helper.rb
Rspec.config do |config| ... # trueだとトランザクションが実行されてしまう config.use_transactional_fixtures = false ... end
以下が失敗用のテストケース。 spec/controllers/counters_controller_spec.rb
require 'spec_helper' describe CountersController do it "works for concurrent increments" do counter = Counter.create! # postgresql用にフォークする前にdisconnect、他のdbには影響なし ActiveRecord::Base.connection.disconnect! process1, process2 = 2.times.map do ForkBreak::Process.new do |breakpoints| # フォークした後に再接続する必要あり ActiveRecord::Base.establish_connection # findが実行された後にbreakpointを設置 original_find = Counter.method(:find) Counter.stub!(:find) do |*args| counter = original_find.call(*args) breakpoints << :after_find counter end get :increment, :id => counter.id end end process1.run_until(:after_find).wait process2.run_until(:after_find).wait process1.finish.wait process2.finish.wait # 親プロセスもあたらしいコネクションを張る必要あり ActiveRecord::Base.establish_connection counter.reload.value.should == 2 end end
テスト実行結果は以下の通り。
$ rspec spec/controllers/counters_controller_spec.rb
CountersController
works for concurrent increments (FAILED - 1)
素晴らしい、期待どおりテストは失敗した。
訳注:テストが失敗するのは、process1とprocess2のfindがともに同じ値を読み込んでおり、インクリメントが実質1回しか呼ばれていないからである。これが競合状態の具体例。
ではコントローラーをどうやって修正するか?例えば、悲観ロックを使ってみよう。
app/controllers/counters_controller.rb
class CountersController < ApplicationController def increment counter = Counter.find(params[:id], lock: true) # 変更点 counter.increment!(:value) render text: counter.value end end
しかし、specテストを実行しても、途中で実行が止まってしまう。(最終的にはデータベース接続がタイムアウトしてエラーになる)
Counter.find
がprocess2
の内部でブロックしているからだ。
訳注:上記のコントローラーの実装でテストを走らせると、まずprocess1のfindがselect ~ for updateの形で実行(process1.run_until(:after_find).wait
)され、次にprocess2も同じようにselect ~ for updateを発行するので、ロック待ちの状態になってしまう。
これを修正するために、上記のテストを少し修正する。
spec/controllers/counters_controller_spec.rb
require 'spec_helper' describe CountersController do it "works for concurrent increments" do counter = Counter.create! # postgresql用にフォークする前にdisconnect、他のdbには影響なし ActiveRecord::Base.connection.disconnect! process1, process2 = 2.times.map do ForkBreak::Process.new do |breakpoints| # フォークした後に再接続する必要あり ActiveRecord::Base.establish_connection # findが実行された後にbreakpointを設置 original_find = Counter.method(:find) Counter.stub!(:find) do |*args| breakpoints << :before_find counter = original_find.call(*args) breakpoints << :after_find counter end get :increment, :id => counter.id end end process1.run_until(:after_find).wait # process2がfind内部で確実にブロックするようにsleepする process2.run_until(:before_find).wait process2.run_until(:after_find) && sleep(0.1) # ここでprocess1を終了させ、process2を待つ process1.finish.wait process2.finish.wait # 親プロセスもあたらしいコネクションを張る必要あり ActiveRecord::Base.establish_connection counter.reload.value.should == 2 end end
テスト実行結果
$ rspec spec/controllers/counters_controller_spec.rb
CountersController
works for concurrent increments
うまくいった!