はじめに
こんにちは、 id:RKTM です。 先日乗鞍岳にバックカントリースキーに行きました。昔は弱音を吐きながら必死で下りたルートを、今回は気持ちよく颯爽と(主観ですが)滑れるようになり、スキー技術の上達を感じて嬉しかったです。
Rails 6.1の新機能:dependent: :destroy_async
を使ってみた
さて、最近MisocaはRails 6.1にアップデートしました。*1
早速Rails 6.1の新機能 dependent: :destroy_async
を導入しましたので、その紹介をします。
dependent: :destroy_async
は、関連を非同期で削除する
Railsの関連を設定する際、 親 has_many :子, dependent: :destroy
という設定を書いた人は多いと思います。これは「親のレコードを削除する際、関連する子レコードも合わせて削除する」というものです。
dependent: :destroy_async
は同じく子レコードを削除するのですが、削除処理を非同期で行う、というものです。名前の通りでわかりやすいですね。
dependent: :destroy_async
はこちらのPull Requestで提案され、マージされました。
github.com
使いどころ:関連先のレコード数が多い場合
dependent: :destroy
で指定した子レコードが多い場合、削除に非常に時間がかかることがあります。特に多くの子レコードが多くの孫レコードを持つ場合は要注意です。オンラインで処理すると最悪の場合リクエストがタイムアウトする可能性があります。
dependent: :destroy_async
を利用することで、時間のかかる削除処理はキューに積み、クライアントに早期にレスポンスを返すことができます。
Misocaで困っていた点
MisocaではMisoca APIの利用のために doorkeeper-gem を使っています。doorkeeper-gemのお作法に従い、下記のようなテーブル関連となっています。(AccessGrantも存在していますが、複雑になるため割愛しています)
Userが親レコードであり、OauthApplicationが子レコード、AccessTokenは孫レコードとなります(これらは has_many :xxx, dependent: :destroy
で関連を定義していました)。
User削除時(≒退会)に、これらレコードの削除に非常に時間がかかる事例が問題となりました。
その原因はMisocaではAccessTokenが非常に多く生成されていたためでした(実際には、indexが張られていなかった、などの理由もありましたが…)。
ということで、Userの削除時にOauthApplicationから先は非同期で削除することになりました。
改修
改修は簡単で、下記のように書き換えるだけです。
before
# User model has_many :oauth_applications, dependent: :destroy
after
# User model has_many :oauth_applications, dependent: :destroy_async
動作確認
自動化されたテストも当然書きますが、ここでは動かして確認してみましょう。
User.last
: 削除対象のユーザー。User.first
:上記とは別のユーザー。 削除対象ユーザーのOauthApplicationに紐づくAccessTokenを保持する。
とします。
ジョブを停めておく
非同期で走るかどうかを確認するため、ジョブを停めておきます。 以降、DelayedJobを使っていますが、ご利用の非同期ライブラリに応じてコマンドを実行してください。
削除対象のユーザーにOauthApplicationを作る
rails console
で
# 2つ登録しておく User.last.oauth_applications.create(name: "aaa", redirect_uri: "http://localhost:3000/") User.last.oauth_applications.create(name: "bbb", redirect_uri: "http://localhost:3000/")
上記で追加したOauthApplicationに対し、別のユーザでAccessTokenを作る
User.first.oauth_access_tokens.create(application_id: OauthApplication.last.id) User.first.oauth_access_tokens # 作られたレコードが表示される
削除対象のユーザーを削除する
User.last.destroy
そうするとジョブが登録されます。
TRANSACTION (0.4ms) BEGIN ↳ (snip) Delayed::Backend::ActiveRecord::Job Create (0.7ms) INSERT INTO `delayed_jobs` (snip) # 下記のようなJobが登録される # job_class: ActiveRecord::DestroyAssociationAsyncJob # owner_model_name: User # owner_id: 317 # association_class: Doorkeeper::Application # association_ids: # ↓削除対象のユーザーが持つOautApplication2つが対象になっている。 # - 11 # - 12
User.last
ユーザーは削除されています。
ジョブは停めてあるため、OauthApplicationはまだ削除されていません。
OauthApplication.all # 削除対象のユーザーが持つOautApplication2つはまだ削除されていない
同じく、別ユーザのAccessTokenは残っています。
User.first.oauth_access_tokens # 作られたレコードが表示される
ジョブを起動して削除処理を走らせる
それではジョブを起動して非同期削除が行われるか確認してみましょう!
OauthApplication.all # 削除対象のユーザーに紐づくoauth_applications は削除される User.first.oauth_access_tokens # 削除されたoauth_applicationsに紐づくレコードが削除されている
はい、意図通り動きました。
挙動で気になる箇所を調べる
has_many, dependent: :destroy_async
で関連レコードが存在しない場合はどうなるの?ジョブは登録されるの?
ジョブは登録されません。無駄にキューを埋めたりはしない実装となっています。
has_many, dependent: :destroy_async
で関連レコードが複数存在する場合、ジョブはそのレコード数分登録されるの?そうなるとキューが溢れるのでは?
上記のリンク先の周辺を見るとわかるとおり、関連レコードが複数存在する場合であっても、登録されるジョブは1つです。
association_ids: ids
とあるように、1ジョブで複数の子レコードのidを受け取れるようになっています。
導入における注意点
キュー溢れに注意
dependent: :destroy_async
をネストするなどで大量にジョブが登録されるとキューが溢れる可能性があります。既存の非同期処理への影響も考慮して設計しましょう。
非同期ジョブが失敗することも考慮する
親を削除した時に "必ず即座に" 削除されてほしい関連(≒子レコードの削除に失敗したならロールバックして親レコードも残って欲しい)には使わないほうが良いでしょう(適切な例は思いつきませんでした)。
最後に
Rails 6.1新機能 dependent: :destroy_async
を紹介しました。この機能を導入することでMisocaでは削除処理の懸念事項が減りました。
便利な機能ですので、ぜひ利用を検討してください。
*1:Rails 6.1リリースから期間が空いた理由は、Regression on Optimistic Locking from 6.0 · Issue #40786 · rails/rails のissueに遭遇したため。