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