Quantcast
Channel: kickflow Tech Blog
Viewing all articles
Browse latest Browse all 22

ネストしたトランザクション

$
0
0

こんにちは、プロダクト開発本部の渡辺です。
先日、山を走るレースをしてきました。冬の山はとにかく服装のレイヤリングが重要です。気をつけていたものの、ベースレイヤーに綿素材のインナーを着てしまい、身体が冷えてお腹を壊してしまいました。
何かを重ねる時、それぞれの特性や役割を意識する重要性を感じて、この記事を書いてみました。

概要

新しい機能を追加する際に、バックエンド側で非同期の処理を実装しました。 その処理の中で既存のサービスオブジェクトを呼び出す箇所があり、トランザクションがネストする状況が発生しました。 ActiveRecordのトランザクションがネストした時に、どういう扱いになるかが曖昧な状態だったので調べてみました。

TL;DR

  • ActiveRecordではネストしたトランザクションを親トランザクションの一部として扱う
  • ネストしたトランザクションの内側で明示的にActiveRecord::Rollbackをしてもロールバックしない
  • ネストしたトランザクションの内側でエラーが起こった時に、全体をロールバックするには親トランザクションにエラーを伝播する必要がある
  • ネストの内側だけロールバックしたい場合はrequires_new: trueを指定し、サブトランザクションだけロールバックする

準備/確認環境

今回確認した環境は以下の通りです。
ruby “3.2.2”
rails “7.2.2.1”
postgresql “15.3”

公式のドキュメント

ネストしたトランザクションに関して、ドキュメントの内容を参照すると下記のように記載があります。

Nested transactions
transaction calls can be nested. By default, this makes all database statements in the nested transaction block become part of the parent transaction. For example, the following behavior may be surprising:


[日本語訳]
ネストされたトランザクション
トランザクションはネストして呼び出すことが可能です。デフォルトでは、ネストされたトランザクションブロック内の全てのデータベース操作が親トランザクションの一部として処理されます。その結果、以下の例に示されるような動作が予期しないものと受け取られる場合があります。

User.transaction do
  User.create(username: 'Kotori')
  User.transaction do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end 

creates both “Kotori” and “Nemu”. Reason is the ActiveRecord::Rollback exception in the nested block does not issue a ROLLBACK. Since these exceptions are captured in transaction blocks, the parent block does not see it and the real transaction is committed.

In order to get a ROLLBACK for the nested transaction you may ask for a real sub-transaction by passing requires_new: true. If anything goes wrong, the database rolls back to the beginning of the sub-transaction without rolling back the parent transaction. If we add it to the previous example:


[日本語訳]
このコードでは、「Kotori」と「Nemu」の両方が作成されます。その理由は、ネストされたブロック内で発生したActiveRecord::Rollbackという例外がROLLBACKを実行しないためです。この例外はトランザクションブロック内で捕捉されるため、親ブロックが例外を認識することはなく、結果として実際のトランザクションがコミットされてしまいます。

ネストされたトランザクションでROLLBACKを実行したい場合は、requires_new: trueオプションを指定して、実際のサブトランザクションを利用するように設定できます。このオプションを使用すると、問題が発生した際にデータベースはサブトランザクションの開始地点までロールバックしますが、親トランザクションには影響を与えません。 以下は、先ほどの例にrequires_new: trueを追加した場合です。

User.transaction do 
  User.create(username: 'Kotori') 
  User.transaction(requires_new: true) do 
    User.create(username: 'Nemu') 
    raise ActiveRecord::Rollback 
  end 
end 

only “Kotori” is created. Most databases don’t support true nested transactions. At the time of writing, the only database that we’re aware of that supports true nested transactions, is MS-SQL. Because of this, Active Record emulates nested transactions by using savepoints. See dev.mysql.com/doc/refman/en/savepoint.html for more information about savepoints.


[日本語訳]
この場合、「Kotori」だけが作成されます。
多くのデータベースでは、ネストされたトランザクションを完全にサポートしていません。この場合、内側のトランザクションがROLLBACKされても外側のトランザクションには影響を与えません。しかし、現在この機能を完全にサポートしていると確認されているデータベースは、MS-SQLのみです。

そのため、Active Recordではネストされたトランザクションを再現するためにセーブポイントを使用しています。セーブポイントは、特定の地点までロールバックを可能にする機能で、ネストされたトランザクションと似た動作を提供します。セーブポイントに関する詳細は、MySQL公式ドキュメント: セーブポイントをご覧ください。

実際の処理の流れ

公式のドキュメントの例ではネストした内側のトランザクションでraise ActiveRecord::Rollbackが実行されています。 説明によると内部のトランザクションブロックで捕捉され、親トランザクションには伝播していないようです。

下記のダミーコードで処理の流れを確認してみます。


1.Rollbackが実行されないパターン

namespace :demo_transaction do
  task :rollback => :environment do
    User.transaction do
      User.create!(name: 'John', email: 'john@example.com')
      
      User.transaction do
        User.create!(name: 'Jane', email: 'jane@example.com')
        raise ActiveRecord::Rollback
      end
    end
  end
end

公式ドキュメント通りネストしたトランザクションの外側のトランザクションでコミットされ、2つのレコードが作成された。

D, [2024-12-20T22:45:06.494931 #124] DEBUG -- : TRANSACTION (0.3ms) BEGIN 
D, [2024-12-20T22:45:06.496865 #124] DEBUG -- : ↳ lib/tasks/demo_transaction.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:45:06.590904 #124] DEBUG -- : User Exists? (2.2ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "john@example.com"], ["LIMIT", 1]] 
D, [2024-12-20T22:45:06.593687 #124] DEBUG -- : ↳ lib/tasks/demo_transaction.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:45:06.604187 #124] DEBUG -- : User Create (1.3ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "John"], ["email", "john@example.com"], ["created_at", "2024-12-20 22:45:06.601536"], ["updated_at", "2024-12-20 22:45:06.601536"]] 
D, [2024-12-20T22:45:06.607128 #124] DEBUG -- : ↳ lib/tasks/demo_transaction.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:45:06.610511 #124] DEBUG -- : User Exists? (1.2ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "jane@example.com"], ["LIMIT", 1]] 
D, [2024-12-20T22:45:06.613535 #124] DEBUG -- : ↳ lib/tasks/demo_transaction.rake:11:in `block (4 levels) in <main>' 
D, [2024-12-20T22:45:06.616315 #124] DEBUG -- : User Create (0.6ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "Jane"], ["email", "jane@example.com"], ["created_at", "2024-12-20 22:45:06.613703"], ["updated_at", "2024-12-20 22:45:06.613703"]] 
D, [2024-12-20T22:45:06.619098 #124] DEBUG -- : ↳ lib/tasks/demo_transaction.rake:11:in `block (4 levels) in <main>' 
D, [2024-12-20T22:45:06.637410 #124] DEBUG -- : TRANSACTION (15.7ms) COMMIT 
D, [2024-12-20T22:45:06.642460 #124] DEBUG -- : ↳ lib/tasks/demo_transaction.rake:8:in `block (2 levels) in <main>' 

---

# User.all 
User Load (1.1ms) SELECT "users".* FROM "users" /* loading for pp */ LIMIT $1 [["LIMIT", 11]] => [#<User:0x00007f701a29fa18 id: 3, name: "John", email: "john@example.com", created_at: "2024-12-20 22:45:06.601536000 +0000", updated_at: "2024-12-20 22:45:06.601536000 +0000">, #<User:0x00007f7019d1ba60 id: 4, name: "Jane", email: "jane@example.com", created_at: "2024-12-20 22:45:06.613703000 +0000", updated_at: "2024-12-20 22:45:06.613703000 +0000">] 

2.Rollbackが実行されるパターン(オプションによる指定)

namespace :demo_transaction_requires_new do
  task :requires_new => :environment do
    User.transaction do
      User.create!(name: 'John', email: 'john@example.com')
      
      User.transaction(requires_new: true) do
        User.create!(name: 'Jane', email: 'Jane@example.com')
        raise ActiveRecord::Rollback
      end
    end
  end
end

TRANSACTION (1.4ms) SAVEPOINT active_record_1の箇所でサブトランザクションが開始しています。TRANSACTION (1.8ms) ROLLBACK TO SAVEPOINT active_record_1の結果、ネストの内側だけがロールバックしました。

# rakeタスクのログ
D, [2024-12-20T22:51:03.651611 #144] DEBUG -- : TRANSACTION (0.8ms) BEGIN 
D, [2024-12-20T22:51:03.654482 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:51:03.743961 #144] DEBUG -- : User Exists? (1.4ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "john@example.com"], ["LIMIT", 1]] 
D, [2024-12-20T22:51:03.745628 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:51:03.770030 #144] DEBUG -- : User Create (2.5ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "John"], ["email", "john@example.com"], ["created_at", "2024-12-20 22:51:03.760300"], ["updated_at", "2024-12-20 22:51:03.760300"]] 
D, [2024-12-20T22:51:03.776019 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:51:03.784037 #144] DEBUG -- : TRANSACTION (1.4ms) SAVEPOINT active_record_1 
D, [2024-12-20T22:51:03.789499 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:11:in `block (4 levels) in <main>' 
D, [2024-12-20T22:51:03.801111 #144] DEBUG -- : User Exists? (15.2ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "jane@example.com"], ["LIMIT", 1]] 
D, [2024-12-20T22:51:03.811640 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:11:in `block (4 levels) in <main>' 
D, [2024-12-20T22:51:03.814772 #144] DEBUG -- : User Create (1.2ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "Jane"], ["email", "jane@example.com"], ["created_at", "2024-12-20 22:51:03.811885"], ["updated_at", "2024-12-20 22:51:03.811885"]] 
D, [2024-12-20T22:51:03.815790 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:11:in `block (4 levels) in <main>' 
D, [2024-12-20T22:51:03.819167 #144] DEBUG -- : TRANSACTION (1.8ms) ROLLBACK TO SAVEPOINT active_record_1 
D, [2024-12-20T22:51:03.820563 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:10:in `block (3 levels) in <main>' 
D, [2024-12-20T22:51:03.837646 #144] DEBUG -- : TRANSACTION (15.6ms) COMMIT 
D, [2024-12-20T22:51:03.838652 #144] DEBUG -- : ↳ lib/tasks/demo_transaction_requires_new.rake:8:in `block (2 levels) in <main>' 

---

# User.all 
User Load (1.0ms) SELECT "users".* FROM "users" /* loading for pp */ LIMIT $1 [["LIMIT", 11]] => [#<User:0x00007f05afc90318 id: 5, name: "John", email: "john@example.com", created_at: "2024-12-20 22:51:03.760300000 +0000", updated_at: "2024-12-20 22:51:03.760300000 +0000">] 

3.Rollbackが実行されるパターン(エラーが伝播される)

namespace :demo_transaction_error do
  task :standard_error => :environment do
    User.transaction do
      User.create!(name: 'John', email: 'john@example.com')
      
      User.transaction do
        User.create!(name: 'Jane', email: 'jane@example.com')
        raise StandardError
      end
    end
  end
end

ネストの内側で発生したStandardErrorによりトランザクション全体がロールバックしました。

# rakeタスクのログ 
D, [2024-12-20T22:55:17.336233 #172] DEBUG -- : TRANSACTION (0.7ms) BEGIN 
D, [2024-12-20T22:55:17.339198 #172] DEBUG -- : ↳ lib/tasks/demo_transaction_error.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:55:17.445100 #172] DEBUG -- : User Exists? (1.4ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "john@example.com"], ["LIMIT", 1]] 
D, [2024-12-20T22:55:17.448697 #172] DEBUG -- : ↳ lib/tasks/demo_transaction_error.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:55:17.466875 #172] DEBUG -- : User Create (1.9ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "John"], ["email", "john@example.com"], ["created_at", "2024-12-20 22:55:17.463704"], ["updated_at", "2024-12-20 22:55:17.463704"]] 
D, [2024-12-20T22:55:17.469598 #172] DEBUG -- : ↳ lib/tasks/demo_transaction_error.rake:9:in `block (3 levels) in <main>' 
D, [2024-12-20T22:55:17.475788 #172] DEBUG -- : User Exists? (3.1ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "jane@example.com"], ["LIMIT", 1]] 
D, [2024-12-20T22:55:17.482466 #172] DEBUG -- : ↳ lib/tasks/demo_transaction_error.rake:11:in `block (4 levels) in <main>' 
D, [2024-12-20T22:55:17.487629 #172] DEBUG -- : User Create (0.7ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "Jane"], ["email", "jane@example.com"], ["created_at", "2024-12-20 22:55:17.483394"], ["updated_at", "2024-12-20 22:55:17.483394"]] 
D, [2024-12-20T22:55:17.492967 #172] DEBUG -- : ↳ lib/tasks/demo_transaction_error.rake:11:in `block (4 levels) in <main>' 
D, [2024-12-20T22:55:17.496862 #172] DEBUG -- : TRANSACTION (1.5ms) ROLLBACK 
D, [2024-12-20T22:55:17.498655 #172] DEBUG -- : ↳ lib/tasks/demo_transaction_error.rake:8:in `block (2 levels) in <main>' rake aborted! StandardError: StandardError (StandardError) 

---

# User.all
User Load (0.9ms) SELECT "users".* FROM "users" /* loading for pp */ LIMIT $1 [["LIMIT", 11]] => [] 

感想

今回の実装ではトランザクション全体がロールバックすることが必要な条件だったので、エラーが伝播されることをテストで担保することにしました。 調べてみて、多くの人が同じ問題を疑問に感じて取り上げていることがわかりました。 requires_new以外にも指定できるオプションが存在しますが、挙動がわかりにくくなるのでトランザクションはなるべくネストしない設計するのが良さそうです。

参考

https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
https://zenn.dev/adverdest/scraps/87bcd521d52046
https://tech.smarthr.jp/entry/2024/02/26/120000

We are hiring!

kickflow(キックフロー)は、運用・メンテナンスの課題を解決する「圧倒的に使いやすい」クラウドワークフローです。

kickflow.com

サービスを開発・運用する仲間を募集しています。株式会社kickflowはソフトウェアエンジニアリングの力で社会の課題をどんどん解決していく会社です。こうした仕事に楽しさとやりがいを感じるという方は、カジュアル面談、ご応募お待ちしています!

careers.kickflow.co.jp


Viewing all articles
Browse latest Browse all 22

Trending Articles