Rails 7.1から7.2へのアップグレードで学んだこと

こんにちは、Tony Duongです。SpacelyでRailsバックエンドエンジニアとして働いており、Spacelyプラットフォームの開発に積極的に取り組んでいます。

私たちの開発しているスペースリーのサービスはRuby on Railsのバージョン7.1で動作していましたが、7.1のEOLに伴ってバージョン7.2へのアップグレードを実施することになりました。このリリースには、アプリケーションに役立ついくつかの改善が含まれており、特に次の2つの機能に期待していました。

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_allimportを使用)時にエラーが発生した
# 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_allimportは書き込みのために行をロックする必要があります。しかし、正確な原因は判明しませんでした。

簡易的なテストとして、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 UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE

参考:https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html

データベースのデフォルトの分離レベルはREAD COMMITTEDで、トランザクションは他のトランザクションによってコミットされたデータのみを見ることができます。

分離レベルをREAD UNCOMMITTEDに変更しました。Railsconfig/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ではトランザクション処理に関するいくつかの変更が導入されました:

これらの変更が、複数データベース環境において破壊的な動作を引き起こした可能性があります。根本原因を理解するには、さらなる調査が必要です。

まとめ

主要なライブラリのバージョンアップグレードは、特にRailsのような大規模なフレームワークでは困難を伴うことがあります。問題が発生すると、問題とコードベースをより深く掘り下げることを余儀なくされ、それがエンジニアとしての成長につながります。

ついにRailsアプリケーションのアップグレードに成功しました(ホッとしました!)。今のところ問題は発生していませんが、引き続き注意深く見守っていきます!

最後に、スペースリーでは一緒に働いてくださる方を大募集中です。 少しでもご興味がある方は上記よりご連絡ください!