Rails 6.1の新機能:dependent: :destroy_async を使ってみた

はじめに

こんにちは、 id:RKTM です。 先日乗鞍岳にバックカントリースキーに行きました。昔は弱音を吐きながら必死で下りたルートを、今回は気持ちよく颯爽と(主観ですが)滑れるようになり、スキー技術の上達を感じて嬉しかったです。

f:id:RKTM:20210510131117j:plain
位ヶ原から乗鞍岳剣ヶ峰方面

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も存在していますが、複雑になるため割愛しています)

f:id:RKTM:20210511160822p:plain

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 で関連レコードが存在しない場合はどうなるの?ジョブは登録されるの?

ジョブは登録されません。無駄にキューを埋めたりはしない実装となっています。

Offer dependent: :destroy_async for associations by adrianna-chang-shopify · Pull Request #40157 · rails/rails · GitHub

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に遭遇したため。