RailsでtransactionのRollbackがうまく発火しなかった話

こんにちは!kossyです!




今回はRailsでtransactionのRollbackがうまく発火しない事象に遭遇したので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.0.3
MacOS catalina



なお、テーブル構成は以下の記事で紹介したものを流用します。

kossy-web-engineer.hatenablog.com

class Company < ApplicationRecord
  with_options dependent: :destroy do
    has_many :employees
    has_many :departments
    has_many :orders
    has_many :positions
  end
end
class Department < ApplicationRecord
  belongs_to :company
  has_many :employees
  has_ancestry cache_depth: true
end
class Employee < ApplicationRecord
  belongs_to :company
  belongs_to :department
  belongs_to :position
  has_many :orders

  enum sex: { male: false, female: true }
end
class Position < ApplicationRecord
  belongs_to :company
  has_many :employees
end

修正前

状況としては、CSVからデータをimportするメソッドを書いてました。

# app/models/employee.rb

  def self.import_from_csv(file_path)
    errors = []
    CSV.read(file_path, headers: true).each do |row|
      ActiveRecord::Base.transaction do
        company = Company.find_by!(name: row['会社名'])
        department = Department.find_by!(name: row['部署名'])
        position = Position.find_or_initialize_by(name: row['役職名'], company: company)

        position.save!

        employee = Employee.find_or_initialize_by(
          company: company,
          department: department,
          position: position,
          sex: row['性別'],
          last_name: row[''],
          first_name: row[''],
          last_name_kana: row['姓カナ'],
          first_name_kana: row['名カナ'],
          hired_at: Date.parse(row['入社日']),
          retired_at: Date.parse(row['退職日'])
        )

        employee.save!

      rescue ActiveRecord::RecordNotFound => e
        errors << 'レコードが見つかりませんでした。'
      rescue ActiveRecord::RecordInvalid => e
        errors << e.record.errors.full_messages.join
      end
    end

    errors
  end

勘のいい人ならばお気づきでしょうが、上記のソースコードの場合、position.save!に成功して、employee.save!に失敗した場合、positionのレコードはrollbackされずcommitされてしまいます。

これはRailsのtransactionが、発生した例外をトリガーにしてロールバックを発生させるためで、
上記で示したコード例のようにActiveRecord::Base.transactionブロックの中で例外をキャッチしてしまうと、ロールバックが起きません。

$ file_path = Pathname.new('/app/tmp/employee.csv')

$ Employee.import_from_csv(file_path)

  (0.5ms)  BEGIN
  Company Load (5.1ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (6.8ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (6.5ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "一般社員"], ["company_id", 1], ["LIMIT", 1]]
  Company Load (1.9ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Employee Load (11.9ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 5], ["sex", false], ["last_name", "上野"], ["first_name", "六海"], ["last_name_kana", "ウエノ"], ["first_name_kana", "ムツミ"], ["hired_at", "2015-04-01"], ["retired_at", "2020-04-01"], ["LIMIT", 1]]
  Company Load (1.5ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Department Load (3.1ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
  Position Load (2.2ms)  SELECT "positions".* FROM "positions" WHERE "positions"."id" = $1 LIMIT $2  [["id", 5], ["LIMIT", 1]]
   (0.4ms)  COMMIT
   (0.4ms)  BEGIN
  Company Load (3.5ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (3.1ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (2.4ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "アルバイト"], ["company_id", 1], ["LIMIT", 1]]
  Position Create (4.3ms)  INSERT INTO "positions" ("name", "company_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "アルバイト"], ["company_id", 1], ["created_at", "2021-06-26 13:19:25.789127"], ["updated_at", "2021-06-26 13:19:25.789127"]]
  Employee Load (3.3ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 10], ["sex", true], ["last_name", "越川"], ["first_name", "佳祐"], ["last_name_kana", "コシカワ"], ["first_name_kana", "ケイスケ"], ["hired_at", "1988-04-01"], ["retired_at", "1989-04-01"], ["LIMIT", 1]]
   (2.6ms)  COMMIT

最後にROLLBACKではなく、COMMITされてしまっています。

なので、ActiveRecord::Base.transactionブロックの外側で例外をキャッチするように修正します。

修正後

  def self.import_from_csv(file_path)
    errors = []
    CSV.read(file_path, headers: true).each do |row|
      begin
        ActiveRecord::Base.transaction do
          company = Company.find_by!(name: row['会社名'])
          department = Department.find_by!(name: row['部署名'])
          position = Position.find_or_initialize_by(name: row['役職名'], company: company)

          position.save!

          employee = Employee.find_or_initialize_by(
            company: company,
            department: department,
            position: position,
            sex: row['性別'],
            last_name: row[''],
            first_name: row[''],
            last_name_kana: row['姓カナ'],
            first_name_kana: row['名カナ'],
            hired_at: Date.parse(row['入社日']),
            retired_at: Date.parse(row['退職日'])
          )

          employee.save!
        end
      rescue ActiveRecord::RecordNotFound => e
        errors << 'レコードが見つかりませんでした。'
      rescue ActiveRecord::RecordInvalid => e
        errors << e.record.errors.full_messages.join
      end
    end

    errors
  end
$ file_path = Pathname.new('/app/tmp/employee.csv')

$ Employee.import_from_csv(file_path)

   (0.5ms)  BEGIN
  Company Load (3.9ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (6.0ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (5.4ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "一般社員"], ["company_id", 1], ["LIMIT", 1]]
  Company Load (1.4ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Employee Load (8.6ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 5], ["sex", false], ["last_name", "上野"], ["first_name", "六海"], ["last_name_kana", "ウエノ"], ["first_name_kana", "ムツミ"], ["hired_at", "2015-04-01"], ["retired_at", "2020-04-01"], ["LIMIT", 1]]
  Company Load (1.6ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Department Load (2.5ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
  Position Load (1.8ms)  SELECT "positions".* FROM "positions" WHERE "positions"."id" = $1 LIMIT $2  [["id", 5], ["LIMIT", 1]]
   (0.6ms)  COMMIT
   (0.4ms)  BEGIN
  Company Load (2.2ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (2.3ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (2.7ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "アルバイト"], ["company_id", 1], ["LIMIT", 1]]
  Position Create (3.8ms)  INSERT INTO "positions" ("name", "company_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "アルバイト"], ["company_id", 1], ["created_at", "2021-06-26 13:10:43.924938"], ["updated_at", "2021-06-26 13:10:43.924938"]]
  Employee Load (4.0ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 9], ["sex", true], ["last_name", "越川"], ["first_name", "佳祐"], ["last_name_kana", "コシカワ"], ["first_name_kana", "ケイスケ"], ["hired_at", "1988-04-01"], ["retired_at", "1989-04-01"], ["LIMIT", 1]]
   (0.6ms)  ROLLBACK

修正後のコードではCOMMITではなく、意図通りROLLBACKが走っています。


まとめ

RailsのtransactionのROLLBACKの仕組みを知っていればハマらないはずでしたが、知らなかったために見事にハマってしまいました。。。

transactionの中で明示的に例外をrescueするコードを書く時に要注意ですね。


大いに参考にさせていただいた記事・サイト

素晴らしいコンテンツの作成誠にありがとうございます。

トランザクション中にrescueするとロールバックしないので注意! - Qiita
[Ruby on Rails] トランザクション中のrescueには気をつけて - YouTube