SmartHR Tech Blog

SmartHR 開発者ブログ

ActiveRecord トランザクションと ActiveStorage をちょっとだけ仲良くさせる方法

こんにちは! SmartHR エンジニアの @gongoZ です。

Ruby on Rails 5.2 より、ファイルアップロードおよびそれらのファイルと ActiveRecord との関連付けを容易にする ActiveStorage という仕組みが導入されました。

お手軽で便利な仕組みなのですが、とある問題に遭遇した際「ここ、もう少しシュッと書けないものだろうか…?」と試行錯誤しました。 本記事ではその問題となったケースと、実際に行った解決策について紹介していきたいと思います。

概要

  • 紹介したいこと
    • ActiveStorage のファイルを削除する場合に ActiveRecord トランザクションと組合わせる場合は、 purge よりも detach が相性が良い
    • ActiveStorage::Blob.unattached を活用していく
  • 想定する環境
    • Ruby on Rails 5.2.1

前提(例題)

突然ですが、下記のような User クラスを想定します。

class User < ApplicationRecord
  has_one_attached :avatar
  validates :name, presence: true
end

user = User.create(name: '堀裕子')
user.avatar.attach(
  io: File.open('/path/to/顔写真.png'),
  filename: '顔写真.png'
)

本記事では ActiveStorage の詳しい説明は割愛しますが、この User クラスと attach した ActiveStorage、アップロードした実体ファイルは概ね下図のような関係になります。

📝 アップロードしたファイルは Amazon S3 に保存することを想定しています

例えばこの状態で、アップロードしたファイルを削除する purge メソッド:

User.find(1).avatar.purge

を実行すると、各テーブルは下記のように変化します。

  1. User.find(1) が持つ avatar に紐付いていた attachments を削除
  2. attachments が紐付いていた blobs を削除
  3. blobs が参照していたファイルを削除

ここでは ActiveRecord クラスとアップロードしたファイル、それらを関連付ける2つのテーブルが存在することを覚えておいてください。

遭遇した問題

ActiveStorage は上記の特性を持つという前提で、実際に私が悩んだロジックについて紹介していきます。

1. purge を無かったことにしたい

下記の仕様を満たしたい! というケースにおいて、つい書いてしまいがちなコードについて考えます。

user = User.find(1)

#
# 指定したユーザの avatar を削除しつつ name を変更したい
# しかし name 更新に失敗したら画像削除は無かったことにしたいな
#
ApplicationRecord.transaction do
  user.avatar.purge # (1)
  user.name = params[:name] # == ''
  user.save!
end # (2) rollback

user.avatar.attached? # => true
user.avatar.download # (3) ... ????

一見問題なさそうですが、致命的なバグが存在しています。

上記のコードだと (2) で rollback が発生し、その効果で active_storage_attachmentsactive_storage_blobs のレコードは復活しますが、 実体ファイルは復活しません

実体ファイルはローカルディスクや S3など、RDB のトランザクションの外で削除処理が発生しているため、 ApplicationRecord.transaction do ; end では rollback できません。こうなってしまうと、いわゆるデータ不整合状態となります。

user.avatar.attached?
# => attachments や blobs レコードは存在するため true

user.avatar.download
# => S3 なら Aws::S3::Errors::AccessDenied
# => ローカルなら Errno::ENOENT

📝 余談

実は save!purge の順序を逆にすると、このケースは解決します 🤗

 ApplicationRecord.transaction do
-  user.avatar.purge
   user.name = params[:name]
   user.save!
+  user.avatar.purge
 end

2. purge を禁止したい

それでは次のケースはどうでしょうか。

class User < ApplicationRecord
  has_one_attached :avatar
  validates :name, presence: true
  validate :avatar_attached
  
  def avatar_attached
    errors.add(:avatar, :presence) if avatar.blank?
  end
end

このように ActiveStorage もバリデーションの対象になっていると、save!purge の順番を変えるだけで解決できません。

ApplicationRecord.transaction do
  user.name = params[:name]
  user.save!
  
  #
  # この purge で ActiveRecord::RecordInvalid は発生しないものの、
  # 「avatar は存在すべき」というルールからは外れることになる
  #
  user.avatar.purge
end

解決案

紹介した「問題になりやすいケース」の解決案として、本記事では下記に紹介する2つの技を合体させて対応していきます。

1. purge ではなく detach を使う

ActiveStorage には、ファイル削除の API として purge の他に detach というものが存在します。

user = User.find(1)

ApplicationRecord.transaction do
  user.avatar.detach # これ!! (1)
  user.name = params[:name] # == ''
  user.save!
end # (2) rollback

  • detachactive_storage_attachments のみを削除します
    • つまり ActiveRecord の rollback で回収できる
  • attached? メソッドは active_storage_attachments レコードが存在しない時点で false を返します
    • つまり「ファイルがアップロードされているか?」の validation を実施できる

この特性を利用することで、ファイルの削除(正確には実体ファイルとの関連付けの解消)を ActiveRecord のトランザクションに含めて記述できるようになり、より直感的になりました。

2. 浮いてしまったデータを定期的に削除する

上図を見るとわかりますが、 detach を使うと active_storage_blobs およびそれが参照している実体ファイルは残ったままになります。このままではディスクリソースの無駄遣いなので、定期的に掃除する必要があります。

その時に使えるコードがこちら:

ActiveStorage::Blob.unattached.find_each(&:purge)

ActiveStorage::Blob.unattached は上図のような宙ぶらりんとなった blobs を取得する scope です。こいつを使って取得した blobs に対して purge を呼び出すことで、定期的に掃除することが可能となります。

まとめ

本記事では ActiveStorage のファイル削除を、より便利にするための一例を紹介しました。

ActiveStorage はまだまだ新しい仕組みで、ActiveStorage 特有のバリデーション(ファイルサイズや拡張子の限定など)も、まだ自前で書く必要があります。

また、今回紹介できなかった便利機能などもあるため、上記の改善も合わせてより使い勝手も上がっていくことを期待しています。

参考記事