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)

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

  • 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

ここで、 _1.update! でエラーが発生するとします。すると……

ロールバックされたとしてもアバターファイルは削除され、このように画像表示できなくなります。

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

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がデータを削除する流れ

まず、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 のタイミングを調整することが考えられます。

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

おそらくこれらには大差がないのですが、以下のようなポイントはあるかもしれません。

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

🔗 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_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 は非同期でファイルを削除するのですが、ジョブが実行される前にロールバックされた場合は、ファイルが削除されずに済みます。


では #purge_later さえ使っておけば、ファイル削除を適切に制御できるかというと、微妙なところだと思います。
なぜなら、ロールバックされる前にジョブが走ると、 before_destroy コールバックによるレコードチェックが誤ってパスする可能性があるからです。

def run
  ActiveRecord::Base.transaction do
    user.delete_avatar!
    # ここで大量にデータ更新していると、ロールバックがジョブの実行に間に合わない可能性がある
    user.gained_reputations.each(&:reset_avatar_impression!)
  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 オプションが手動で設定されていません。
また、話をわかりやすくするために、 avatar の削除処理を以下のように分割します。

ActiveRecord::Base.transaction
  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では、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。