palindrome!

Dammit, I'm mad!

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

うまくいっているように見える?でも findincrement!の間に競合状態がある。

訳注:ここでいう競合状態(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.findprocess2の内部でブロックしているからだ。

訳注:上記のコントローラーの実装でテストを走らせると、まず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

うまくいった!