このエントリは、SmartHR Advent Calendar 2022の7日目です。
SmartHRではマルチテナントの実現のために activerecord-multi-tenantというGemを使っているのですが、そのGemを調査したときに気づいたことを書きたいと思います。
TL;DR
activerecord-multi-tenantとActsAsTenantはリクエストごとにテナントを初期化するタイミングが違うよ。- とくにrequest specでは誤ったテストの原因になりかねないので、テナントが初期化されるタイミングを理解して実際のアプリケーションの動作になるだけ近づけよう。
テナントのリセットタイミングの再現コード
Railsにおいてマルチテナントを行う際に有力な選択肢として
あたりが有力な選択肢になるかと思われます。
(Apartmentも有名ですが先述の2つのGemと異なりマルチスキーマでマルチテナントを構成するGemで検証が間に合わなかったので言及しません :bow: )
どちらのGemもリクエストごとに設定されているテナントを初期化してくれる機能が提供されていますが、その初期化をするタイミングが微妙に異なっています。
↓サンプルコードです。
require 'bundler/inline'
gemfile(true) do
source 'https://rubygems.org'
gem 'rails', '7.0.4'
gem 'sqlite3', '1.5.4'
gem 'activerecord-multi-tenant', '2.1.6', require: false
gem 'acts_as_tenant', '0.5.3'
gem 'rspec-rails'
end
require 'action_controller/railtie'
require 'active_record'
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
require 'activerecord-multi-tenant'
class App < Rails::Application
routes.append do
get '/tenant_test' => 'tenant#test'
end
end
class TenantController < ActionController::API
def test
puts "ActsAsTenant.current_tenant: #{ActsAsTenant.current_tenant}"
puts "ActsAsTenant.test_tenant: #{ActsAsTenant.test_tenant}"
puts "MultiTenant.current_tenant: #{MultiTenant.current_tenant}"
render json: { hello: :world }
end
end
App.configure do
config.hosts.clear
end
require 'acts_as_tenant/test_tenant_middleware'
Rails.application.configure do
config.middleware.use ActsAsTenant::TestTenantMiddleware
end
App.initialize!
require 'rspec/rails'
RSpec.describe 'Test', type: :request do
it 'first request' do
ActsAsTenant.current_tenant = 'Tenant set in spec'
ActsAsTenant.test_tenant = 'Tenant set in spec'
MultiTenant.current_tenant = 'Tenant set in spec'
get '/tenant_test'
expect(response.status).to eq(200)
end
it 'second request' do
get '/tenant_test'
expect(response.status).to eq(200)
end
end
実行結果
⋊> ~/acts_as_tenant_sandbox rspec -f d script.rb
Fetching gem metadata from https://rubygems.org/...........
###(中略)###
Test
ActsAsTenant.current_tenant: Tenant set in spec
ActsAsTenant.test_tenant:
MultiTenant.current_tenant:
first request
ActsAsTenant.current_tenant:
ActsAsTenant.test_tenant:
MultiTenant.current_tenant:
second request
Finished in 0.00789 seconds (files took 1.34 seconds to load)
2 examples, 0 failures
実行結果からわかる通りActsAsTenant.current_tenantはテストコード内で設定したテナントがrequest specの機能で呼び出されたアプリケーションの中でも初期化されずに引き継がれていることがわかります。
この問題(?)についてはActsAsTenantのREADMEにて記載があります。
If you set the
current_tenantin your tests, make sure to clean up the tenant after each test by callingActsAsTenant.current_tenant = nil. Integration tests are more difficult: manually setting thecurrent_tenantvalue will not survive across multiple requests, even if they take place within the same test. This can result in undesired boilerplate to set the desired tenant. Moreover, the efficacy of the test can be compromised because the setcurrent_tenantvalue will carry over into the request-response cycle.
また対処の方法としてサンプルコードでも使用していたActsAsTenant.test_tenantの使用を推奨する記載もREADMEにあります。
なぜ初期化するタイミングが違うのか
なぜ初期化するタイミングが違うのかというとActsAsTenantとMultiTenantではテナントの保存に使用しているクラスが異なっているからです。
ActsAsTenantはRequestStoreを使っています。
対してMultiTenantはActiveSupport::CurrentAttributesを使っています。
どちらもThreadクラスを利用しつつリクエストごとに値を初期化してくれる場所を提供してくれるクラスですが、初期化を設定する方法が異なっています。
RequestStoreはRackに値を初期化するミドルウェアを追加しているのと、RailsのExecuterのto_compliteコールバックに値を初期化する動作が追加 されています。
Executerのコールバックについての細かい解説はRailsガイドにあります(いつもありがとうございます!)。
ミドルウェアの中身は↓のようになっており、RequestStoreはリクエストを処理した後にのみ値を初期化していることがわかります。
def call(env)
RequestStore.begin!
status, headers, body = @app.call(env)
body = Rack::BodyProxy.new(body) do
RequestStore.end!
RequestStore.clear!
end
...
対してActiveSupport::CurrentAttributesではRails のExecutorのコールバックであるto_runとto_completeに値を初期化する動作を追加しています。
先述のガイドにものっていますが、to_runコールバックはアプリケーションの実行前に呼び出されるコールバックです。
↓は超ざっくりイメージです。

初期化されるタイミングの違いで起こりうること
request specで使えるようになるget等のメソッドはテストと同じスレッドで擬似的にRackアプリ全体を呼び出すため、RequestStoreを使用しているActsAsTenant.current_tenantはテスト内で設定したcurrent_tenantの値が初期化されずに引き継がれます。
request specを統合テストと考えると事前にテナントが固定されたままテストするのはあまりよくないのでリクエストを処理する前に値を初期化してくれるミドルウェアに対応しているActsAsTenant.test_tenantを使用したほうが良いでしょう。
実は activerecord-multi-tenantも古いバージョンではRequestStoreを使用しており、ActsAsTenant.current_tenantと同様にrequest spec内で固定したテナントがget等で呼び出すアプリケーション内で初期化されず引き継がれるようになっていました。
request specを書く際にはテストのためにデータを準備するコードでテナントを固定する箇所は発生すると思いますので、
テナントがスレッド内でどのように固定と初期化されているのか理解しておくことで間違ったテストを書く可能性が減らせると思います。