こんにちは、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アプリケーションのアップグレードに成功しました(ホッとしました!)。今のところ問題は発生していませんが、引き続き注意深く見守っていきます!
最後に、スペースリーでは一緒に働いてくださる方を大募集中です。 少しでもご興味がある方は上記よりご連絡ください!