Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: ActiveStorageでファイルを削除するときは、単にnilで更新するだけでいい

🔗 理由

コミット後にActiveStorage::Attachment#purge_dependent_blob_laterが呼び出されてファイルが削除されるから。

※ただし has_one_attached / has_many_attached:dependent オプションに :purge_later 以外が設定されている場合は不可

よって、こうじゃなくて、

user.avatar.purge

こうでいい。

user.update(avatar: nil)

むしろ #purge を利用すると、DBのロールバックが発生したとき、ファイルだけ削除された状態になりかねない。


検証した環境は以下のとおりです。

  • Ruby: 3.2.0
  • Ruby on Rails: 7.0.7

🔗 とりあえずファイルの復元に失敗してみる

ActiveStorageのファイルを削除するには #purge を呼び出す必要がある、という話はそれなりに知られていると思います。
Railsガイドにも書いてあります。

参考: §4 ファイルを削除する -- Active Storage の概要 - Railsガイド

では、これをtransaction内で呼んでみます。
あまり現実的ではないですが、以下のようなビジネスロジックが存在するとします。

  • ユーザーのアバターを削除し、キャプションの中身を空にする
  • 同時に、アバターに対してついている他のユーザーからのアバター評価も空にする
class User < ApplicationRecord
  has_one_attached :avatar
  has_many :reputations, class_name: 'UserReputation', foreign_key: 'user_id'
  has_many :gained_reputations, class_name: 'UserReputation', foreign_key: 'target_user_id'

  def delete_avatar
    ActiveRecord::Base.transaction do
      update!(caption: nil)
      avatar.purge
      gained_reputations.each { _1.update!(avatar_impression: nil) }
    end
    true
  rescue
    false
  end
end

ここで考えられる問題点は、transactionの内部で例外が発生したとき、DBではロールバックが起きるにもかかわらず、ファイルだけが削除されてしまうことです。

実際に gained_reputations.each { _1.update!(avatar_impression: nil) }! の実行時に例外が発生したとします。すると……

ロールバック処理が行われているにも関わらずアバターファイルは削除され、このように画像表示できなくなります。

一方で、 user.avataruser.avatar.blob といった、ActiveStorageの情報は残存しています。

irb(main):036:0> user.reload
=> ...
irb(main):037:0> user.avatar
=> 
#<ActiveStorage::Attached::One:0x00007ff3e0243278
 @name="avatar",
 @record=
  #<User:0x00007ff3e1bc0d90 ...>>
irb(main):038:0> user.avatar.blob
  ActiveStorage::Attachment Load (0.6ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3 LIMIT $4  [["record_id", 1], ["record_type", "User"], ["name", "avatar"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (0.3ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2  [["id", 10], ["LIMIT", 1]]
=> 
#<ActiveStorage::Blob:0x00007ff3e01e3850 ...>

🔗 purgeがデータを削除する流れ

なぜこのようなことになってしまうのか理解するため、Railsのコードを読み解いていきます。
以下、ActiveStorage周りのモデルやメソッドについて、主要なものを一覧化します。

クラス名 役割 上記例における相当スニペット 対応するテーブル
ActiveRecord::Base ファイルを持つモデル user.class.superclass.superclass users
ActiveStorage::Attached::One(ActiveStorage::Attached::Many) モデルが持つファイルを表現する user.avatar.class -
ActiveStorage::Attachment レコードとblobを関連付ける user.avatar.send(:purge_one).attachment.class active_storage_attachments
ActiveStorage::Blob ファイルメタデータと、ファイルがサービス上のどこに存在するかのキーを持つ user.avatar.send(:purge_one).attachment.blob.class active_storage_blobs
ActiveStorage::Service::DiskService
※具体的なサービスクラスは config/storage.yml 等の設定によって変わります
ファイルを保持しているサービス user.avatar.send(:purge_one).attachment.blob.service.class -

user.avatar.purge が呼び出されたときには、アプリケーションは以下の順でメソッドを呼び出し続け、最終的にはファイルが削除されます。

ということで、 user.avatar.purge は以下の3つを削除していることがわかります。

  • active_storage_attachments テーブルのレコード
  • active_storage_blobs テーブルのレコード
  • ファイル(variant含む)

当然ながら、ロールバックでは削除されたファイルを復元できません。

🔗 purgeのタイミング調整

ファイルだけが削除されないようにする回避策として、 avatar.purge を実行するタイミングの調整が考えられます。
DBの更新処理を呼び出し切ってから初めてファイルを削除する、という手順を踏んでいきます。
具体的な実行タイミングには以下のようなものが考えられます。

  • transaction内の末尾で #purge する
  • transactionの外で #purge する
  • after_commit#purge する

おそらくどの方法を取っても大差はないのですが、以下のようなポイントはあるかもしれません。

  • #purge が万一予期せぬ例外を投げる可能性までケアしたいなら、transaction内の末尾で #purge する
  • #purge が例外を投げないと見なすのであれば、transactionの外や after_commit#purge する

🔗 transaction内でのpurge_later

そうはいっても、ファイル削除を含む処理がメソッドで抽象化されていて、 #purge の実行タイミングを調整しづらい場合もあると思います。

class UserAnonymization
  include ActiveModel::Model
  include ActiveModel::AttributeAssignment
  attr_accessor :user

  def run
    ActiveRecord::Base.transaction do
      user.delete_avatar! # このタイミングでファイルを削除しているため、次の行で例外が発生するとファイルだけが削除されてしまう
      user.gained_reputations.each(&:reset_avatar_impression!)
    end
    true
  rescue
    false
  end
end
class User < ApplicationRecord
  has_one_attached :avatar
  has_many :reputations, class_name: 'UserReputation', foreign_key: 'user_id'
  has_many :gained_reputations, class_name: 'UserReputation', foreign_key: 'target_user_id'

  def delete_avatar!
    update!(caption: nil)
    avatar.purge
  end
end
class UserReputation < ApplicationRecord
  belongs_to :user
  belongs_to :target_user, class_name: 'User', foreign_key: 'target_user_id'

  def reset_avatar_impression!
    update!(avatar_impression: nil)
  end
end

この場合は、ファイル削除のために #purge ではなく #purge_later を呼び出す、という対処方法があります。

transaction内から呼び出すファイル削除処理としては、実は #purge よりも #purge_later の方が推奨されています。

参考: ActiveStorage::Blob#purge_later

Enqueues an ActiveStorage::PurgeJob to call purge. This is the recommended way to purge blobs from a transaction, an Active Record callback, or in any other real-time scenario.
ActiveStorage::Blob#purge_laterより

#purge_later は非同期でファイルを削除します。
ファイル削除のジョブが実行される前にロールバックが発生すれば、ファイルは削除されずに残ったままになります。

先ほどのスニペットを持ち出して説明してみます。

class UserAnonymization
  include ActiveModel::Model
  include ActiveModel::AttributeAssignment
  attr_accessor :user

  def run
    ActiveRecord::Base.transaction do
      user.delete_avatar! # ① ファイル削除を含むDB更新処理を呼び出す
      user.gained_reputations.each(&:reset_avatar_impression!) # ④ ここで例外が発生するとする
    end
    true
  rescue
    false
  end
  # ⑤ ロールバックが発生し、②で行われた attachments レコードの削除が取り消される
  # ⑥ ③でenqueueしたジョブが⑤以前に実行されていない場合、このタイミングで実行される。ただし実行前に before_destroy コールバックが走り、 attachments の存在有無をチェックする。 attachments が⑤でロールバックされているなら例外が発生し、実際にはファイルが削除されない
end
class User < ApplicationRecord
  has_one_attached :avatar
  has_many :reputations, class_name: 'UserReputation', foreign_key: 'user_id'
  has_many :gained_reputations, class_name: 'UserReputation', foreign_key: 'target_user_id'

  def delete_avatar!
    update!(caption: nil)
    avatar.purge_later # ② attachments レコードを削除する
    # ③ ファイル削除のジョブをenqueueする。この時点ではまだファイルは削除されない
  end
end

では #purge_later によってファイル削除を完全に制御できるかというと、微妙なところだと思います。
なぜなら、ロールバックの前にジョブ実行が完了するパターンがありえるからです。

例えば以下のスニペットで、 user.gained_reputations.each(&:reset_avatar_impression!) が10万件程度のレコードを削除しているとしましょう。
ちょうど10万件目のレコード削除で例外が発生した場合、既にファイル削除ジョブは完了しているかもしれません。 #purge_later では削除したファイルを復元するような制御は行っていません。

def run
  ActiveRecord::Base.transaction do
    user.delete_avatar! # ① attachments レコードを削除する
    # ② ファイル削除のジョブをenqueueする
    user.gained_reputations.each(&:reset_avatar_impression!) # ③ 大量のレコードを削除する
    # ④ ③のレコード削除中に、②でenqueueしたジョブが実行される。 attachments は①で削除済みなので、ファイルは実際に削除される
    # ⑤ ④のあとに、まだ実行中の③で例外が発生する。DBではロールバックが発生するが、④で削除されたファイルは戻ってこない
  end
  true
rescue
  false
end

ということで困ってしまいます。

🔗 ファイルに対応する属性をnilで更新する

ここまで辿ってみて、ようやく冒頭の話になります。
以下のように、 avatar を単純にnilで更新すると、コミットが発生したあとにファイルが削除されるようになります。コミット前にファイルが削除されないので、今までの懸念は解消されます。

class User < ApplicationRecord
  def delete_avatar!
    update!(caption: nil, avatar: nil)
  end
end

順を追って説明します。
前提として、 User クラスの has_one_attached :avatar には :dependent オプションを設定していないとします。
また、話をわかりやすくするために、 update!(caption: nil, avatar: nil) の部分を以下のように分割しておきます。

ActiveRecord::Base.transaction
  user.caption = nil
  user.avatar = nil # ①
  user.save! # ②
  # ...
end # ③

まず、のタイミングで以下の処理が呼び出されます。

①を実行したことにより、のタイミングで、以下の順で処理が呼び出されます。

この user.avatar_attachment の正体は ActiveStorage::Attachment です。
ここにnilが代入されているため、このままコミットが発生すると active_storage_attachments テーブルのレコードが削除されます。

そして、②の処理を呼び出したことにより、でコミットされたタイミングで、以下の順で処理が呼び出されます。

:dependent オプションのデフォルト値は :purge_later なので、あとは非同期でファイルが削除されていきます。


ということで、Railsのコードを眺めていった結果、 #purge / #purge_later を明示的に呼び出す意味はあまりなく、むしろロールバックのときに一貫性が保証できないんじゃないか、という考えに至りました。
とはいえ何か見落としているような気もするので、ご意見のある方はX(Twitter)等でご指摘お願いします。

この一連の考察は、sakaharaさんが社内チャンネルで #purge / #purge_later の挙動を質問・調査されたことを発端に始まりました。ありがとうございます。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。