🗂

dependent: :destroy_async は削除前に関連レコードのActiveRecordオブジェクトを全てメモリに乗せてしまう

2024/01/25に公開

こんにちは、simomu です。 今回は ActiveRecord の dependent: :destroy_async の話をします。
以降は断りがない場合は Ruby on Rails 7.0 時点での話とします。

ActiveRecord の dependent: :destroy_async

Rails 6.1 から、モデルの関連先を削除するときの挙動を指定する dependent オプションに destroy_async という設定が追加されています。

dependent: :destroy_async を設定すると、モデルの関連先の削除を destroy が呼ばれた時にその場で削除処理を行うのではなく、非同期に削除処理を行うようになります。非同期処理には ActiveJob が利用されます。

一見この機能は、削除するモデルの関連先のレコード数が多い時に役に立ちそうに思えます。has_many 先のレコード数が多く dependent: :destroy の場合だと時間がかかりすぎてタイムアウトするため、dependent: :destroy_async で削除処理は裏側で進めてもらって、レスポンスは先に返してしまうイメージです。

class User < ApplicationRecord
  has_many :posts, dependent: :destroy_async # 数百万ほどのレコードが存在する関連
end

class Post < ApplicationRecord
end

しかしながら、非同期に削除する処理の実行前に関連先の全レコードの ActiveRecord オブジェクトを一度メモリに乗せる実装になっているようで、迂闊に関連先のレコード数が多いところで使用すると、一度に大量のメモリを消費することになります。

dependent: :destroy_async の実装を見てみる

Rails が dependent: :destroy_async 設定がされている関連先から、実際に関連先削除用の Job(DestroyAssociationAsyncJob)をキューイングする箇所がactive_record/associations/has_many_associations.rb にあります。
https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/associations/has_many_association.rb#L29-L51

上記の実装を見ると、親モデルの destroy 処理時に、関連のモデルのクラス名と関連のレコードの id 配列を取得し enqueue_destroy_association を呼ぶ(=DestroyAssociationAsyncJob をキューイングする) ことがわかります。

https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/associations/has_many_association.rb#L42-L49

1つの has_many 関連に対して1つの DestroyAssociationAsyncJob が実行され、
更にはその引数には has_many 関連先のモデルのクラス名や全レコード分の id を入れているようです。

この「関連先の全レコード分の id 」を取得しているのが以下の箇所です。

https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/associations/has_many_association.rb#L38-L40

この target.collect do ... で関連先の全レコード分の id の配列を作成しています[1]
この実装を見る限り、 pluck で id だけを SELECT しているわけでもないので、この時点で target に存在しているのは関連先の全レコードの ActiveRecord オブジェクトの配列ということになります。

一方、実際に関連先のレコードの削除を行う DestroyAssociationAsyncJob の方では、ジョブ実行時に与えられた ID の配列から find_each を用いて少しずつ削除処理を行っているため、Job 側では対象レコードの ActiveRecord オブジェクトが一度にメモリに乗るようなことはなさそうです。
https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/destroy_association_async_job.rb#L26-L28

そもそも dependent: :destroy_async が実装された経緯

Rails 側に dependent: :destroy_async が提案されたのは以下のPull Request で、
モチベーションにはこう書かれています。

https://github.com/rails/rails/pull/36912

Perhaps a model has associations that are destroyed on deletion which in turn trigger other deletions and this can continue down a complex tree.
(機械翻訳)おそらくモデルには、削除時に破壊される関連付けがあり、それが他の削除の引き金となり、複雑なツリーへと続いていくのだろう。

これを読む限りは、 dependent: :destroy_async は関連先が大量のレコードを持っている時のためというよりは、関連先のモデルの子、孫、ひ孫…と関連先で更に削除する必要がある関連が連なっていて、削除に時間がかかるケースを想定しているようでした。

Rails 7.1 の destroy_association_async_batch_size でどうなるか

Rails 7.1 からは destroy_association_async_batch_size なるオプションが追加され、destroy_async で非同期に削除する Job が分割してキューイングされるようになります。

https://github.com/rails/rails/pull/44617

しかしながら、実装を見る限りは削除する Job は分割されているものの、ids.each_size で対象 ID 配列を分割しているだけで、元になっている id 配列は変わらず関連先全件の ActiveRecord オブジェクトの配列から取得してきているように見え、 destroy_association_async_batch_size を入れても大量の関連先削除に関しては同様の問題が発生するものと思われます。

https://github.com/rails/rails/blob/7-1-stable/activerecord/lib/active_record/associations/has_many_association.rb#L36-L54

まとめ

ActiveRecord の dependent: :destroy_async は、関連先のレコード数が多い箇所での使用には向いてなさそうという話をしました。

もちろん削除対象の関連先レコード数がメモリに乗る範囲で収まるのであれば有効だと思われますが、
関連先が数百万単位のレコードを対象にする場合は注意が必要です。

新しい機能を使おうと思ったときには、その機能が実装された経緯や目的等も調べた上で、
適用しようとしている箇所が用途として想定されているかどうかをきちんと把握したほうが良さそうという話でした。

脚注
  1. ruby-jp Slack で聞いてみたところ、load_target が呼ばれた時点で association 先が解決されて、target 関連先の全レコードの ActiveRecord オブジェクトの配列が入ることになる、とのことでした。 ↩︎

SocialPLUS Tech Blog

Discussion