こんにちは、Tony Duongです。SpacelyでRailsバックエンドエンジニアとして働いており、Spacelyプラットフォームの開発に積極的に取り組んでいます。
私たちの開発しているスペースリーのサービスはRuby on Railsのバージョン7.1で動作していましたが、7.1のEOLに伴ってバージョン7.2へのアップグレードを実施することになりました。このリリースには、アプリケーションに役立ついくつかの改善が含まれており、特に次の2つの機能に期待していました。
- クエリパフォーマンス分析:
ActiveRecord::Relationに新しく追加された.explainメソッドにより、モデルから直接データベースクエリの分析と最適化がより簡単にできるようになりました (https://blog.saeloun.com/2024/11/21/rails-7-2-adds-support-for-explain-method-to-activerecord-relation) - より安全なバックグラウンドジョブの実行: トランザクション内でエンキューされたジョブが即座に実行されることを防ぐ重要な修正です。ジョブはトランザクションがコミットされた後に適切に待機するようになり、潜在的な競合状態やデータの不整合を回避できます (https://guides.rubyonrails.org/7_2_release_notes.html#prevent-jobs-from-being-scheduled-within-transactions)
Rails 8.0はすでに利用可能でしたが、リスクを最小限に抑え、互換性の問題を切り分けるために、7.2への段階的なアップグレードパスを選択しました。移行は比較的スムーズに進むと予想していましたが、実際にはそうではありませんでした。このアップグレードにより、5,333個のテストのうち101個が失敗し、その中には診断と修正が非常に困難なものもありました。
Context
- 各テストはトランザクション内で実行され、最後にロールバックされることで、各テストが独立した環境で実行されることを保証しています。以下の設定を使用しています:
RSpec.configure do |config|
config.use_transactional_fixtures = true
end
- 私たちのWebアプリケーションは複数のデータベースに接続しており、データの取得や更新のためにそれらの間でJOIN操作を頻繁に実行しています。
class RealEstate < ApplicationRecord
connects_to database: { writing: :real_estates, reading: :real_estates_replica }
has_many :projects, dependent: :destroy
end
class Project < ApplicationRecord
connects_to database: { writing: :default, reading: :default_replica }
belongs_to :real_estate
end
① ActiveRecord::LockWaitTimeout 例外
何らかの理由で、一部のテストで以下のエラーが発生しました:
ActiveRecord::LockWaitTimeout:
Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction
ActiveRecord::LockWaitTimeoutエラーは、トランザクションがテーブルや行のロックを取得しようと待機しているが、タイムアウト期間を超えた場合に発生します。
このエラーが発生したテストとコードを調査した結果、いくつかの共通パターンを見つけました:
- クロスデータベースJOINが存在していた
- 一括更新(
update_allやimportを使用)時にエラーが発生した
# spec/services/update_projects_service_spec.rb
require "rails_helper"
RSpec.describe UpdateProjectsService, type: :service do
describe "#perform" do
let!(:project) { create(:project) }
let!(:real_estate) { create(:real_estate, available: 1) }
before do
project.real_estate = real_estate
project.save!
end
it "runs successfully" do
projects = Project.all.joins(:real_estate).where(real_estate: { available: 1 })
projects.update_all(title: "test") # ActiveRecord::LockWaitTimeout
end
end
end
私たちの仮説 🕵
エラーの原因として、テストがすでにトランザクション内で実行されているという仮説を立てました。update_allとimportは書き込みのために行をロックする必要があります。しかし、正確な原因は判明しませんでした。
簡易的なテストとして、use_transactional_fixturesを無効にしてみました:
config.use_transactional_fixtures = false
テストが通りました!トランザクションが原因でした。しかし、この状態を維持したくはありませんでした。なぜなら、テストが独立しなくなるためです。
次の設定で、条件付きでuse_transactional_fixturesを無効にしようとしました:
config.before do |example|
if example.metadata[:use_truncation]
config.use_transactional_fixtures = false
end
end
config.after do |example|
if example.metadata[:use_truncation]
# logic for truncating the database
end
end
しかし、うまくいきませんでした。一度use_transactional_fixturesを設定すると、テスト実行中に動的に変更できないようです。
# spec/services/update_projects_service_spec.rb
require "rails_helper"
RSpec.describe UpdateProjectsService, type: :service do
describe "#perform" do
...
it "runs successfully", :use_truncation do
...
end
end
end
解決策 ✅
解決策としてテスト実行前に開いているトランザクションを手動でコミットして閉じることにしました。
config.before do |example|
if example.metadata[:use_truncation]
# Forcefully exit ALL transactions using raw SQL
ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
connection = pool.lease_connection
transaction_count = connection.open_transactions
# Force close all transactions using raw SQL COMMIT
# This bypasses ActiveRecord's transaction tracking
transaction_count.times do
connection.execute("COMMIT")
rescue StandardError
# If COMMIT fails, try ROLLBACK
begin
connection.execute("ROLLBACK")
rescue StandardError
nil
end
end
# Reset ActiveRecord's internal transaction counter
# This is necessary because we bypassed its tracking with raw SQL
connection.instance_variable_set(:@transaction_manager, ActiveRecord::ConnectionAdapters::TransactionManager.new(connection))
end
end
end
config.after do |example|
if example.metadata[:use_truncation]
# Clean up using TRUNCATE across all database connections
ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
connection = pool.lease_connection
# Make sure we're not in a transaction
begin
connection.execute("COMMIT") if connection.open_transactions.positive?
rescue StandardError
nil
end
tables_to_truncate = connection.tables - ["schema_migrations", "ar_internal_metadata"]
next if tables_to_truncate.empty?
connection.execute("SET FOREIGN_KEY_CHECKS = 0")
tables_to_truncate.each do |table|
connection.execute("TRUNCATE TABLE #{table}")
end
connection.execute("SET FOREIGN_KEY_CHECKS = 1")
end
end
end
美しくはありませんが、うまくいきました!必要なテストにuse_truncationメタデータを使用することでテストが通るようになりました。
# spec/services/update_projects_service_spec.rb
require "rails_helper"
RSpec.describe UpdateProjectsService, type: :service do
describe "#perform" do
...
it "runs successfully", :use_truncation do
...
end
end
end
② クロスデータベースJOINが正しく動作しない
一部のテストでは、レコードが作成されているにもかかわらず、複数のデータベースを結合するSQLクエリでレコードを取得できませんでした。
RealEstate.joins(:company).count
=> 0
RealEstate.first.company
=> #<Company:0x00007fffd1ac3410
id: 9912
...
私たちの仮説 🕵
各データベースは独自のトランザクションを開始し、データベースに変更がコミットされません。各テストはトランザクション内にカプセル化されています(実際には複数のデータベースがあるため、私たちの場合は複数のトランザクションです)。
Database_AのcompanyのテーブルとDatabase_Bのreal_estateのテーブルを結合してレコードを取得しようとします。しかし、companyとreal_estateのレコードをそれぞれ作成しても、トランザクション分離により何も取得できない可能性があります。
解決策 ✅
調査の結果、トランザクション内でコミットされていない変更を読み取ることを可能にするために、トランザクション分離レベルを調整できることを発見しました。
4つの分離レベルがあります:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。
参考:https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html
データベースのデフォルトの分離レベルはREAD COMMITTEDで、トランザクションは他のトランザクションによってコミットされたデータのみを見ることができます。
分離レベルをREAD UNCOMMITTEDに変更しました。Railsはconfig/database.ymlファイルでこれを設定する方法を提供しています:
test:
default:
<<: *default
database: default_test
variables:
sql_mode: TRADITIONAL
foreign_key_checks: 0
transaction_isolation: 'READ-UNCOMMITTED' # Here!
real_estates:
<<: *real_estates
database: real_estates_test
variables:
sql_mode: TRADITIONAL
foreign_key_checks: 0
transaction_isolation: 'READ-UNCOMMITTED' # Here!
問題のあったテストを再度実行すると、バージョン7.1のときと同じように通りました!🎉
これは適切な修正というよりも回避策のように感じられます。他の解決策を見つけることはできませんでしたが、問題はテスト環境でのみ発生するため、とりあえずこれで進めることにしました。
これらの問題の原因は何か?
これらの問題の原因となった正確なコードを特定することはできませんでした。
Rails 7.2ではトランザクション処理に関するいくつかの変更が導入されました:
- https://guides.rubyonrails.org/7_2_release_notes.html#prevent-jobs-from-being-scheduled-within-transactions
- https://guides.rubyonrails.org/7_2_release_notes.html#per-transaction-commit-and-rollback-callbacks
これらの変更が、複数データベース環境において破壊的な動作を引き起こした可能性があります。根本原因を理解するには、さらなる調査が必要です。
まとめ
主要なライブラリのバージョンアップグレードは、特にRailsのような大規模なフレームワークでは困難を伴うことがあります。問題が発生すると、問題とコードベースをより深く掘り下げることを余儀なくされ、それがエンジニアとしての成長につながります。
ついにRailsアプリケーションのアップグレードに成功しました(ホッとしました!)。今のところ問題は発生していませんが、引き続き注意深く見守っていきます!
最後に、スペースリーでは一緒に働いてくださる方を大募集中です。 少しでもご興味がある方は上記よりご連絡ください!

著者
Tony Duong
Software Engineer
Full-stack developer passionate about building great products. Love TypeScript, Vue.js, and Ruby on Rails.
GitHubでフォロー

