📖

RSpecとDatabaseCleanerを使ってマルチスレッドで同じレコードを見えるようにしたい

2021/06/12に公開

はじめに

Ruby on Rails 4を触る機会があり、その環境でマルチスレッドを実行させてレコードを確認する作業を行っていました。
その際、RSpecを使用して確認していたのですが、マルチスレッドでのそれぞれのスレッドで、見えるレコード数が違っていることに気づきました。
この現象が気になったので、調べることにしました。

使用バージョン

Ruby: 2.4.10
Ruby on Rails: 4.2.10
MySQL: 5.7
rspec-rails: 4.1.2
database_cleaner: 1.99.0

マルチスレッドをRSpecで実行したときのコードと現象

マルチスレッドのコードを用意し、RSpecを実行してスレッド内外でレコードを確認する単純なケースで確認しました。
現象発生時のコードは以下のとおりです。

user_spec.rb
RSpec.describe User, type: :model do
  describe '#run_thread' do
    before do
      User.create(name: 'TestUser', email: 'test_user@example.com')
    end
    it 'レコード確認' do
      described_class.new.run_thread
    end
  end
end
user.rb
class User < ActiveRecord::Base
  def run_thread
    threads = []

    show_user_records('main_thread')

    threads.push(thread_1)
    threads.push(thread_2)
    threads.each { |t| t.value }

    show_user_records('main_thread')
  end

  def thread_1
    Thread.new do
      show_user_records('thread_1')
    end
  end

  def thread_2
    Thread.new do
      show_user_records('thread_2')
    end
  end

  def show_user_records(thread_name)
    if User.count.positive?
      puts "User.count in #{thread_name}: #{User.count}"
      puts "User.last.inspect in #{thread_name}: #{User.last.inspect}"
    else
      puts "No user in #{thread_name}"
    end
  end
end

実行結果:

bash-5.0# bin/spring rspec spec/models/user_spec.rb
Running via Spring preloader in process 81
User.count in main_thread: 1
User.last.inspect in main_thread: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 00:33:48", updated_at: "2021-06-12 00:33:48">
No user in thread_2
No user in thread_1
User.count in main_thread: 1
User.last.inspect in main_thread: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 00:33:48", updated_at: "2021-06-12 00:33:48">
.

スレッドをはさむとレコードが見えなくなるようでした。
そのため、別スレッドからでもレコードを見えるようにする方法を調べました。

先に結論

以下2つのどちらかを行うことで対応できます。

  • use_transactional_fixturesfalseにして、DatabaseCleanerを使用しない
  • use_transactional_fixturesfalseにして、DatabaseCleanerのstrategyにセットする値をtransaction以外にする

調査

RSpecでのトランザクション

別スレッドでレコードが見えなくなることについて調べると、以下mediumの記事が見つかりました。
https://medium.com/swlh/deep-dive-into-database-transactions-in-rspec-a3d883b35c0

ここでは、

According to the above implementation, ActiveRecord Database connections are per-thread.
Therefore, the default database transactions managed by Rails via use_transactional_fixtures are only available in the main thread.
This technically means, given the transaction rollback strategy, database records of one thread will not be available to other threads.

とあります。
これをまとめると、

  • データベースのコネクションはスレッドごと
  • use_transactional_fixturesを使ったRuby on Railsでのデフォルトのトランザクションは、メインスレッドでのみ利用可能
  • トランザクション、ロールバックのストラテジによって、あるスレッドのデータベースのレコードは他のスレッドで利用できない

となります。
rails_helper.rbのuse_transactional_fixturesを見ると、以下コメントが付いていました。

rails_helper.rb
RSpec.configure do |config|
・・・
  # If you're not using ActiveRecord, or you'd prefer not to run each of your
  # examples within a transaction, remove the following line or assign false
  # instead of true.
  config.use_transactional_fixtures = true
・・・
end

このコメントを見ると、ActiveRecordを使っていなかったり、各テストでトランザクションを使いたくない場合は、削除するかfalseにしてねとあります。

ここまで調べてみて、use_transactional_fixturesfalseにすればトランザクションは使われなくなり、他のスレッドからレコードが見えるはずと考えました。
さっそくfalseにして再実行させました。

bash-5.0# bin/spring rspec spec/models/user_spec.rb
Running via Spring preloader in process 90
User.count in main_thread: 1
User.last.inspect in main_thread: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 02:59:48", updated_at: "2021-06-12 02:59:48">
No user in thread_1
No user in thread_2
User.count in main_thread: 1
User.last.inspect in main_thread: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 02:59:48", updated_at: "2021-06-12 02:59:48">
.

変わらずでした。まだどこかでトランザクションが効いているようです。

DatabaseCleanerのトランザクション

さきほどのMediumの記事でDatabaseCleanerについて少し記載があったため、DatabaseCleanerについて調べました。

https://github.com/DatabaseCleaner/database_cleaner

https://qiita.com/immrshc/items/25942acc1d1bd78ef9c3

DatabaseCleanerではstrategyの値によって振る舞いが変わるとのことです。

strategyによってDatabaseCleanerの振る舞いが変わる。
選択肢としてはtransaction, truncationとdeletionの三つがある。
・transaction: transactionを張ってrollbackする
・truncation: TRUNCATE TABLE文を実行する
・deletion: DELETE文を実行する

今の私のコードを見てみると、transactionのストラテジとなっていました。
そのため、テストごとにトランザクションがはられるようです。

rails_helper.rb
RSpec.configure do |config|
・・・
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  config.before(:each) do
    DatabaseCleaner.start
  end

  config.after(:each) do
    DatabaseCleaner.clean
  end
・・・
end

実際にトランザクションをはっている箇所、ロールバックしているところがどこなのか気になったため、コードを追ってみました。
strategytransactionがセットされることにより、DatabaseCleaner.startDatabaseCleaner.cleanを実行すると、Configurationクラスを通じてTransactionクラスのstartメソッド、cleanメソッドを実行するようです。
これらのメソッドでトランザクションの対応を行っているようなので、それぞれのメソッドについて確認しました。

startメソッドの追跡

まずはstartメソッドから見てみます。

configuration.rb
module DatabaseCleaner
・・・
  class Configuration
・・・
    def start
      connections.each { |connection| connection.start }
    end
・・・

このstartメソッドでTransactionクラスのstartメソッドを呼び出します。

transaction.rb
module DatabaseCleaner::ActiveRecord
  class Transaction
    include ::DatabaseCleaner::ActiveRecord::Base
    include ::DatabaseCleaner::Generic::Transaction

    def start
      # Hack to make sure that the connection is properly setup for
      # the clean code.
      connection_class.connection.transaction{ }

      if connection_maintains_transaction_count?
        if connection_class.connection.respond_to?(:increment_open_transactions)
          connection_class.connection.increment_open_transactions
        else
          connection_class.__send__(:increment_open_transactions)
        end
      end
      if connection_class.connection.respond_to?(:begin_transaction)
        connection_class.connection.begin_transaction :joinable => false
      else
        connection_class.connection.begin_db_transaction
      end
    end
 ・・・

ここではconnection_class.connection.transaction{ }にて、接続確認をするためにトランザクションを行っているようです。
ここのtransactionメソッドはdatabase_statements.rbのtransactionメソッドとなるようです。

database_statements.rb
module ActiveRecord
  module ConnectionAdapters # :nodoc:
    module DatabaseStatements
・・・
      def transaction(options = {})
        options.assert_valid_keys :requires_new, :joinable, :isolation

        if !options[:requires_new] && current_transaction.joinable?
          if options[:isolation]
            raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
          end
          yield
        else
          transaction_manager.within_new_transaction(options) { yield }
        end
      rescue ActiveRecord::Rollback
        # rollbacks are silently swallowed
      end
・・・

このメソッドの中のwithin_new_transactionメソッドを見てみます。

transaction.rb
module ActiveRecord
  module ConnectionAdapters
・・・
    class TransactionManager #:nodoc:
・・・
      def begin_transaction(options = {})
        transaction =
          if @stack.empty?
            RealTransaction.new(@connection, options)
          else
            SavepointTransaction.new(@connection, "active_record_#{@stack.size}", options)
          end
        @stack.push(transaction)
        transaction
      end

      def commit_transaction
        @stack.pop.commit
      end
・・・
      def within_new_transaction(options = {})
        transaction = begin_transaction options
        yield
      rescue Exception => error
        rollback_transaction if transaction
        raise
      ensure
        unless error
          if Thread.current.status == 'aborting'
            rollback_transaction if transaction
          else
            begin
              commit_transaction
            rescue Exception
              transaction.rollback unless transaction.state.completed?
              raise
            end
          end
        end
      end
・・・

within_new_transactionメソッドでbegin_transactionメソッドを実行しています。ここでRealTransactionクラス、もしくはSavepointTransactionクラスのインスタンスを格納している@stackの有無を確認し、BEGIN、もしくはSAVEPOINTのクエリを実行しています。その後commit_transactionメソッドにて、COMMITもしくはRELEASE SAVEPOINTを行ってます。

ここまででトランザクションを使用した接続確認ができたので、startメソッドにある、connection_class.connection.begin_transaction :joinable => falseを追っていきます。
ここで呼ばれているbegin_transactionメソッドは、上記コードのbegin_transactionメソッドであるため、ここでトランザクションをかけているようです。

そのため、DatabaseCleaner.startは、

  1. トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行
  2. いったん1でかけていたトランザクションをCOMMIT、もしくはRELEASE SAVEPOINTする
  3. 再度トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行

の流れになります。

cleanメソッドの追跡

次にcleanメソッドを見ていきます。

configuration.rb
module DatabaseCleaner
・・・
  class Configuration
・・・
    def clean
      connections.each { |connection| connection.clean }
    end

startメソッドと同じくTransactionクラスのcleanメソッドを呼び出します。

transaction.rb
module DatabaseCleaner::ActiveRecord
  class Transaction
    include ::DatabaseCleaner::ActiveRecord::Base
    include ::DatabaseCleaner::Generic::Transaction
・・・
    def clean
      connection_class.connection_pool.connections.each do |connection|
        next unless connection.open_transactions > 0

        if connection.respond_to?(:rollback_transaction)
          connection.rollback_transaction
        else
          connection.rollback_db_transaction
        end

        # The below is for handling after_commit hooks.. see https://github.com/bmabey/database_cleaner/issues/99
        if connection.respond_to?(:rollback_transaction_records, true)
          connection.send(:rollback_transaction_records, true)
        end

        if connection_maintains_transaction_count?
          if connection.respond_to?(:decrement_open_transactions)
            connection.decrement_open_transactions
          else
            connection_class.__send__(:decrement_open_transactions)
          end
        end
      end
    end
・・・

コネクションプールからconnectionを取り出しロールバック処理を行っていきます。
connection.rollback_transactionが呼び出されるので、rollback_transactionメソッドを追ってみます。

transaction.rb
module ActiveRecord
  module ConnectionAdapters
・・・
    class TransactionManager #:nodoc:
・・・
      def rollback_transaction
        @stack.pop.rollback
      end
・・・

この@stackstartメソッドのコードリーディング中にあったものと同じになります。
そのため、@stackにはRealTransactionクラスもしくはSavepointTransactionクラスのインスタンスが含まれているので、格納しているインスタンスによって、COMMITもしはRELEASE SAVEPOINTのクエリが実行されます。

そのため、DatabaseCleaner.cleanでは、

  • 実行しているトランザクションに応じて、COMMIT、もしくはRELEASE SAVEPOINTする

を行っています。

DatabaseCleanerのトランザクションストラテジのまとめ

startcleanメソッドをテストの前後に実行することで、

  1. トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行
  2. いったん1でかけていたトランザクションをCOMMIT、もしくはRELEASE SAVEPOINTする
  3. 再度トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行
  4. テスト実行
  5. 実行しているトランザクションに応じて、COMMIT、もしくはRELEASE SAVEPOINTする

となります。

同じレコードを見えるようにするにはどうしたら良いか?

今回の問題の対応には以下2つのどちらかで対応できます。

  • use_transactional_fixturesfalseにして、DatabaseCleanerを使用しない
  • use_transactional_fixturesfalseにして、DatabaseCleanerのstrategyにセットする値をtransaction以外にする

今回は2番目の方法を採用しました。
strategyにセットする値をtruncationにして、実行しました。

bash-5.0# bin/spring rspec spec/models/user_spec.rb
Running via Spring preloader in process 99
User.count in main_thread: 1
User.last.inspect in main_thread: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 03:00:59", updated_at: "2021-06-12 03:00:59">
User.count in thread_2: 1
User.last.inspect in thread_2: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 03:00:59", updated_at: "2021-06-12 03:00:59">
User.count in thread_1: 1
User.last.inspect in thread_1: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 03:00:59", updated_at: "2021-06-12 03:00:59">
User.count in main_thread: 1
User.last.inspect in main_thread: #<User id: 1, name: "TestUser", email: "test_user@example.com", created_at: "2021-06-12 03:00:59", updated_at: "2021-06-12 03:00:59">
.

別スレッドからでもちゃんとレコードが見えていますね。

おわりに

この記事では、RSpecでマルチスレッド実行時に同じレコードを見えるようにしました。
同じレコードを見る方法が分かったことに加え、トランザクションのタイミング、複数トランザクションが実行されたときにBEGINとSAVEPOINTを使い分けることまで知ることができました。
この記事が誰かのお役に立てれば幸いです。

Discussion