🔗 理由
コミット後に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.avatar
や user.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
が呼び出されたときには、アプリケーションは以下の順でメソッドを呼び出し続け、最終的にはファイルが削除されます。
ActiveStorage::Attached::One#purge
ActiveStorage::Attached::Changes::PurgeOne#purge
ActiveStorage::Attachment#purge
: ここでactive_storage_attachments
テーブルのレコードを削除ActiveStorage::Blob#purge
: ここでactive_storage_blobs
テーブルのレコードを削除ActiveStorage::Blob#delete
ActiveStorage::Service::DiskService#delete
: ここでファイルを削除ActiveStorage::Service::DiskService#delete_prefixed
: ここでファイルのvariantを削除
ということで、 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 callpurge
. 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
は非同期でファイルを削除します。
ファイル削除のジョブが実行される前にロールバックが発生すれば、ファイルは削除されずに残ったままになります。
- ActiveStorage::Attachment#purge_later
active_storage_attachments
テーブルのレコードを削除ActiveStorage::Blob#purge_later
: ファイルを削除するジョブをenqueueActiveStorage::PurgeJob.perform_later
: ファイル削除を非同期実行ActiveStorage::Blob#purge
ActiveStorage::Blob
のbefore_destroy
コールバック
: ファイルを削除する前にDBのattachments
の存在有無を確認する。ロールバックによってattachments
が残っていればファイルの#purge
は中止される
先ほどのスニペットを持ち出して説明してみます。
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 # ③
まず、①のタイミングで以下の処理が呼び出されます。
- ActiveStorage::Attached::Modelによる代入処理
:user.attachment_changes['avatar']
にActiveStorage::Attached::Changes::DeleteOne
のインスタンスを代入
①を実行したことにより、②のタイミングで、以下の順で処理が呼び出されます。
ActiveStorage::Attached::Model
のafter_save
コールバックActiveStorage::Attached::Changes::DeleteOne#save
:user.avatar_attachment = nil
を実行する
この user.avatar_attachment
の正体は ActiveStorage::Attachment
です。
ここにnil
が代入されているため、このままコミットが発生すると active_storage_attachments
テーブルのレコードが削除されます。
そして、②の処理を呼び出したことにより、③でコミットされたタイミングで、以下の順で処理が呼び出されます。
ActiveStorage::Attachment
のafter_destroy_commit
コールバックActiveStorage::Attachment#purge_dependent_blob_later
:has_one_attached
の:dependent
オプションが:purge_later
であれば、blob&.purge_later
を呼び出してファイルを削除する
:dependent
オプションのデフォルト値は :purge_later
なので、あとは非同期でファイルが削除されていきます。
ということで、Railsのコードを眺めていった結果、 #purge
/ #purge_later
を明示的に呼び出す意味はあまりなく、むしろロールバックのときに一貫性が保証できないんじゃないか、という考えに至りました。
とはいえ何か見落としているような気もするので、ご意見のある方はX(Twitter)等でご指摘お願いします。
この一連の考察は、sakaharaさんが社内チャンネルで #purge
/ #purge_later
の挙動を質問・調査されたことを発端に始まりました。ありがとうございます。