dependent: :destroy_async は削除前に関連レコードのActiveRecordオブジェクトを全てメモリに乗せてしまう
こんにちは、simomu です。 今回は ActiveRecord の dependent: :destroy_async
の話をします。
以降は断りがない場合は Ruby on Rails 7.0 時点での話とします。
dependent: :destroy_async
ActiveRecord の 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
にあります。
上記の実装を見ると、親モデルの destroy 処理時に、関連のモデルのクラス名と関連のレコードの id 配列を取得し enqueue_destroy_association
を呼ぶ(=DestroyAssociationAsyncJob
をキューイングする) ことがわかります。
1つの has_many
関連に対して1つの DestroyAssociationAsyncJob
が実行され、
更にはその引数には has_many
関連先のモデルのクラス名や全レコード分の id を入れているようです。
この「関連先の全レコード分の id 」を取得しているのが以下の箇所です。
この target.collect do ...
で関連先の全レコード分の id の配列を作成しています[1]。
この実装を見る限り、 pluck で id だけを SELECT しているわけでもないので、この時点で target
に存在しているのは関連先の全レコードの ActiveRecord オブジェクトの配列ということになります。
一方、実際に関連先のレコードの削除を行う DestroyAssociationAsyncJob
の方では、ジョブ実行時に与えられた ID の配列から find_each
を用いて少しずつ削除処理を行っているため、Job 側では対象レコードの ActiveRecord オブジェクトが一度にメモリに乗るようなことはなさそうです。
dependent: :destroy_async
が実装された経緯
そもそも Rails 側に dependent: :destroy_async
が提案されたのは以下のPull Request で、
モチベーションにはこう書かれています。
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
は関連先が大量のレコードを持っている時のためというよりは、関連先のモデルの子、孫、ひ孫…と関連先で更に削除する必要がある関連が連なっていて、削除に時間がかかるケースを想定しているようでした。
destroy_association_async_batch_size
でどうなるか
Rails 7.1 の Rails 7.1 からは destroy_association_async_batch_size
なるオプションが追加され、destroy_async
で非同期に削除する Job が分割してキューイングされるようになります。
しかしながら、実装を見る限りは削除する Job は分割されているものの、ids.each_size
で対象 ID 配列を分割しているだけで、元になっている id 配列は変わらず関連先全件の ActiveRecord オブジェクトの配列から取得してきているように見え、 destroy_association_async_batch_size
を入れても大量の関連先削除に関しては同様の問題が発生するものと思われます。
まとめ
ActiveRecord の dependent: :destroy_async
は、関連先のレコード数が多い箇所での使用には向いてなさそうという話をしました。
もちろん削除対象の関連先レコード数がメモリに乗る範囲で収まるのであれば有効だと思われますが、
関連先が数百万単位のレコードを対象にする場合は注意が必要です。
新しい機能を使おうと思ったときには、その機能が実装された経緯や目的等も調べた上で、
適用しようとしている箇所が用途として想定されているかどうかをきちんと把握したほうが良さそうという話でした。
-
ruby-jp Slack で聞いてみたところ、
load_target
が呼ばれた時点で association 先が解決されて、target
関連先の全レコードの ActiveRecord オブジェクトの配列が入ることになる、とのことでした。 ↩︎
Discussion