RSpecとDatabaseCleanerを使ってマルチスレッドで同じレコードを見えるようにしたい
はじめに
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を実行してスレッド内外でレコードを確認する単純なケースで確認しました。
現象発生時のコードは以下のとおりです。
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
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_fixtures
をfalse
にして、DatabaseCleanerを使用しない -
use_transactional_fixtures
をfalse
にして、DatabaseCleanerのstrategy
にセットする値をtransaction
以外にする
調査
RSpecでのトランザクション
別スレッドでレコードが見えなくなることについて調べると、以下mediumの記事が見つかりました。
ここでは、
According to the above implementation, ActiveRecord Database connections are per-thread.
Therefore, the default database transactions managed by Rails viause_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
を見ると、以下コメントが付いていました。
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_fixtures
をfalse
にすればトランザクションは使われなくなり、他のスレッドからレコードが見えるはずと考えました。
さっそく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について調べました。
DatabaseCleanerではstrategyの値によって振る舞いが変わるとのことです。
strategyによってDatabaseCleanerの振る舞いが変わる。
選択肢としてはtransaction, truncationとdeletionの三つがある。
・transaction: transactionを張ってrollbackする
・truncation: TRUNCATE TABLE文を実行する
・deletion: DELETE文を実行する
今の私のコードを見てみると、transaction
のストラテジとなっていました。
そのため、テストごとにトランザクションがはられるようです。
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
実際にトランザクションをはっている箇所、ロールバックしているところがどこなのか気になったため、コードを追ってみました。
strategy
にtransaction
がセットされることにより、DatabaseCleaner.start
、DatabaseCleaner.clean
を実行すると、Configuration
クラスを通じてTransaction
クラスのstart
メソッド、clean
メソッドを実行するようです。
これらのメソッドでトランザクションの対応を行っているようなので、それぞれのメソッドについて確認しました。
start
メソッドの追跡
まずはstart
メソッドから見てみます。
module DatabaseCleaner
・・・
class Configuration
・・・
def start
connections.each { |connection| connection.start }
end
・・・
このstart
メソッドでTransaction
クラスのstart
メソッドを呼び出します。
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
メソッドとなるようです。
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
メソッドを見てみます。
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
は、
- トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行
- いったん1でかけていたトランザクションをCOMMIT、もしくはRELEASE SAVEPOINTする
- 再度トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行
の流れになります。
clean
メソッドの追跡
次にclean
メソッドを見ていきます。
module DatabaseCleaner
・・・
class Configuration
・・・
def clean
connections.each { |connection| connection.clean }
end
start
メソッドと同じくTransaction
クラスのclean
メソッドを呼び出します。
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
メソッドを追ってみます。
module ActiveRecord
module ConnectionAdapters
・・・
class TransactionManager #:nodoc:
・・・
def rollback_transaction
@stack.pop.rollback
end
・・・
この@stack
はstart
メソッドのコードリーディング中にあったものと同じになります。
そのため、@stack
にはRealTransaction
クラスもしくはSavepointTransaction
クラスのインスタンスが含まれているので、格納しているインスタンスによって、COMMITもしはRELEASE SAVEPOINTのクエリが実行されます。
そのため、DatabaseCleaner.clean
では、
- 実行しているトランザクションに応じて、COMMIT、もしくはRELEASE SAVEPOINTする
を行っています。
DatabaseCleanerのトランザクションストラテジのまとめ
start
、clean
メソッドをテストの前後に実行することで、
- トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行
- いったん1でかけていたトランザクションをCOMMIT、もしくはRELEASE SAVEPOINTする
- 再度トランザクションをかけるためにBEGINもしくはSAVEPOINTのクエリ実行
- テスト実行
- 実行しているトランザクションに応じて、COMMIT、もしくはRELEASE SAVEPOINTする
となります。
同じレコードを見えるようにするにはどうしたら良いか?
今回の問題の対応には以下2つのどちらかで対応できます。
-
use_transactional_fixtures
をfalse
にして、DatabaseCleanerを使用しない -
use_transactional_fixtures
をfalse
にして、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