1
/
5

最近得た ActiveRecord::Core::freeze に関するメンタルモデルの話 / 問題解決テクニックの一例紹介

Photo by Mr. Daaaa on Unsplash

こんにちは。市古 (wantedly: @igsr5, threads: @igsr5_) です。
今回はバックエンド (特に Ruby on Rails) 開発者として知っておくと役に立ちそうな情報を詰め込んでいきます。

話すこと

  • 筆者が最近得た新たな Ruby on Rails に関するメンタルモデルの話
    • 主に ActiveRecord::Core::freeze 周りの話
  • 上記のメンタルモデルを得るまでの過程
    • よく分からない問題の解決した事例の紹介
    • 問題解決の方法に完全な 1 つの正解はないが、1 テクニックとして使えるかも

前置き

  • Ruby on Rails v7.0.6, Ruby v3.2.2 時点で話をしています
  • この記事では度々 "メンタルモデル" という単語を利用しますが、この記事内ではこの単語を以下のような意味で扱います
    • メンタルモデル: mental model)とは、頭の中にある「ああなったらこうなる」といった「行動のイメージ」を表現したものである(引用: メンタルモデル - Wikipedia

この記事の要約

記事本文ではいくつか寄り道もしたいので先に要約を載せておきます。なお問題解決のコツに関する話は長いので省いています。気になる方は是非本文まで。

Q. Model クラスの after_destroy で attributes の更新はできる?できない?
A. できない

  • after_destroy 内で attributes を更新しようとするとエラーになる (FrozenError)
    • 具体的にはDBレイヤでの destroy が完了したタイミングで、モデルインスタンスの @attributesfreeze されている (ActiveRecord::Core::freeze)
  • では、なぜafter_destroy 内で attributes を更新すると FrozenError で失敗するのか?
    • それは after_destroy 時点では既に DB にレコードが存在せず、attributes に対する変更を行っても永続化ができないため (ソース)
  • ⭐ この事実をもとに after_destroy に関係なく「変更を永続化できない状態ということは @attributes は freeze されているな」というメンタルモデルが頭の中に構築されていると便利
    • これがこの記事で伝えたいこと

Q. 社内に 10 年前から after_destroy で attributes の更新してるコードがあるんだけどこれ何、、?
A. third-party gem によって論理削除されていた!

  • ここで先ほどの「変更を永続化できない状態ということは @attributes は freeze されているな」というメンタルモデルが役に立つ
  • 論理削除はその性質上 DB にレコード自体は残るので、削除後もレコードに対して変更を加えることができる
    • ということは、@attributes も freeze されていないはず
  • この挙動を抑えておかないと ActiveRecord::Attributes 周りで本来動作しないはずのコードを見つけた時に頭を悩ますことになる
    • 場合によってはバグを仕込むことに、、(筆者の実体験)

以上がこの記事の要約です。
結論、普段から技術的な仕様や機能を点で捉えず多面的な視点で見ることによってより活用可能なメンタルモデルを培っていくことは重要ということが言いたいです。

それでは本文に移ります。

ことはじめ

以下のようなコードを考えます。
ちなみに今回みたいに軽く Ruby on Rails で検証コードを書きたい時は rails/rails リポジトリの bug_report 用のテンプレートファイルをよく使ってます。1ファイルで簡単に実行できてお手軽です。

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
source "https://rubygems.org"

git_source(:github) { |repo| "https://github.com/#{repo}.git" }

gem "rails", '7.0.6'
gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
create_table :posts, force: true do |t|
t.string :title
end

create_table :comments, force: true do |t|
t.string :title
t.datetime :deleted_at
end
end

class Post < ActiveRecord::Base
after_destroy do
self.title = "foo"
end
end

class AfterDestroyTest < Minitest::Test
def test_after_destroy
post = Post.create!(title: "bar")

assert_equal 1, Post.count
assert_equal post.title, "bar"

assert_raises(FrozenError) do
post.destroy!
end
end
end

after_destroy 内で attributes を更新すると FrozenError で失敗する

ここで話したいのは以下のコードです。

class Post < ActiveRecord::Base
after_destroy do
# FrozenErrorが発生!
self.title = "foo"
end
end

class AfterDestroyTest < Minitest::Test
def test_after_destroy
post = Post.create!(title: "bar")

assert_equal 1, Post.count
assert_equal post.title, "bar"

assert_raises(FrozenError) do
post.destroy!
end
end
end

まず Rails の知識として、after_destroy 内で attributes を更新しようとするとエラーになります。上記のコードを実際に手元で実行してみるとわかりやすいと思います。

ただしここまでは Ruby on Rails にさほど詳しくなくても知識として知っている方も多いかと思いますが、なぜそうなっているか?については意外と忘れ去られがちです。

なぜafter_destroy 内で attributes を更新すると FrozenError で失敗するのか?

いきなり結論から書いてしまいますが、それは after_destroy 時点では既に DB にレコードが存在せず、attributes に対する変更を行っても永続化ができないためです。

このことは ActiveRecord::Core::freeze (旧 ActiveRecord::Base::freeze) が実装された際の commit 2023/07 時点の最新の ActiveRecord::Persistence::destroy の実装を見ると分かります。

# from. https://github.com/rails/rails/blob/v7.0.6/activerecord/lib/active_record/persistence.rb#L676

# Deletes the record in the database and freezes this instance to reflect
# that no changes should be made (since they can't be persisted).
#
# There's a series of callbacks associated with #destroy. If the
# <tt>before_destroy</tt> callback throws +:abort+ the action is cancelled
# and #destroy returns +false+.
# See ActiveRecord::Callbacks for further details.
def destroy
_raise_readonly_record_error if readonly?
destroy_associations
@_trigger_destroy_callback = if persisted?
destroy_row > 0
else
true
end
@destroyed = true
freeze
end

また後で詳しく書きますが、この事実をもとに after_destroy とは関係なく「変更を永続化できない状態ということは @attributes は freeze されているな」というメンタルモデルが頭の中に構築されていると普段の Ruby on Rails アプリケーションの開発で良いことがあります。

10年前から after_destroy で attributes が更新されてるんだけど、、

さてここで (プチ) 事件です。先ほど

after_destroy 内で @attributes を更新しようとするとエラーになります。

と書きましたが、Rails リポジトリで開発を行なっていると after_destroy で attributes を更新しているのにエラーになっていないコードが発見されました。しかもこのコードは10年前から存在しており現在に至るまで壊れることなく元気に動き続けています。
しかもこれだけならびっくり案件で済みますが、該当の after_destroy を改修時に他のモデルに移したところ急に FrozenError が発生してしまい動かなくなってしまいました。

このままでは困ってしまうので事件解決に向け調査します。

問題解決まで

結論を書いてしまえば早いのですが、せっかくなので筆者が真実に辿り着くまでの道のりも紹介しておきます。原因が早く知りたい方は記事冒頭の要約を読むかスクロールしてください。

① まず該当リポジトリで再現テスト (Regression Test) を書く
実装で何かよく分からないことが起こっているときはとりあえず再現テスト (Regression Test) を書いてみましょう。事実整理にも役立ちますし、その後の調査にも使えます。

② FrozenError がなぜ発生しているのかを明確にする
エラーの内容的に「問題のモデルで @attributes が freeze されていないんだな」という予想はある程度働きますが、個人的によく分からないことが起こっているときは初手で推測に頼らず適切に事実を収集していくことが重要だと思っています。(より正確には推測で終わらせず、事実確認をするということが言いたい)

ちなみに ActiveRecord::Core::freeze にこんなモンキーパッチを当ててログを取りました。

module ActiveRecord::Core::FreezeWithLogging
def freeze
log_attributes
super
end

private

def log_attributes
p "feeze class: #{self.class}"
p "freeze method targets: #{@attributes.to_h}"
end
end

ActiveRecord::Core.prepend ActiveRecord::Core::FreezeWithLogging

③ 分からないことを明確にする

user.destroy が正常に終了するケースでも Profile モデルは1回も freeze されない。この謎を解く

と書いてある部分です。ここまでである程度情報が集まっているので、当初の「after_destroy 内で attributes がなぜか更新できる」という問題を「destroy されているのになぜか freeze が呼ばれない」という問題に狭めてみて考えます。

④ 少し手詰まったのでもう少し情報を集めてみる
ここまでで 「destroy されているのになぜか freeze が呼ばれない」問題を解けばいいことまで分かりましたが、しばらく思うように原因を特定できず手詰まってしまいます。

そういう時は一旦立ち止まって再度情報を集めましょう。ここで自分は 「AcitiveRecord::Persistence::destroy がなんかおかしいな」という推測のもと AcitiveRecord::Persistence::destroy の caller を確認しにいきました。


⑤ 怪しそうな実装に気づく
ここで事態が進展します。④ で取得した AcitiveRecord::Persistence::destroy の caller を眺めてみると

lib/ruby/gems/3.0.0/gems/paranoia-2.4.3/lib/paranoia.rb:61:in `paranoia_destroy'

paranoia という third-party gem の実装が挟まっていることに気づきます。paranoia は Ruby on Rails で論理削除を実現するための gem です。なんだか怪しい匂いがします。

GitHub - rubysherpas/paranoia: acts_as_paranoid for Rails 5, 6 and 7
acts_as_paranoid for Rails 5, 6 and 7. Contribute to rubysherpas/paranoia development by creating an account on GitHub.
https://github.com/rubysherpas/paranoia


⑥ テストを書く
さてここでもう一度テストを書きます。今度は ⑤ で発見した paranoia gem の利用の有無で問題再現を確認してみます。

# 物理削除するモデル
class Post < ActiveRecord::Base
after_destroy do
self.title = "foo"
end
end

# 論理削除するクラス​
class Comment < ActiveRecord::Base
after_destroy do
self.title = "foo"
end

acts_as_paranoid # こいつを追加
end


class AfterDestroyTest < Minitest::Test
def test_after_destroy
post = Post.create!(title: "bar")

assert_equal 1, Post.count
assert_equal post.title, "bar"

assert_raises(FrozenError) do
post.destroy!
end
end
end


class AfterDestroyWithParanoiaTest < Minitest::Test
def test_after_destroy
comment = Comment.create!(title: "bar")

assert_equal 1, Comment.count
assert_equal comment.title, "bar"

assert_raises(FrozenError) do # 通らない
comment.destroy!
end
end
end

上記のコードを実行してみると...

F

Failure:
AfterDestroyWithParanoiaTest#test_after_destroy [./activerecord_after_destroy_freeze.rb:92]:
FrozenError expected but nothing was raised.

rails test ./activerecord_after_destroy_freeze.rb:83


Finished in 0.003708s, 539.3743 runs/s, 1618.1228 assertions/s.
2 runs, 6 assertions, 1 failures, 0 errors, 0 skips

テストが落ちました!落ちたテストは次の箇所です。

assert_raises(FrozenError) do # 通らない
comment.destroy!
end

本来 FrozenError が raise されるべきタイミングで FrozenError が raise されていません。これは今調べている問題と同様の現象です。


⑦ 問題解決へ
ここまででほとんど問題を解決できたようなものですが、⑥ では書き捨てのRubyスクリプトを利用していたので念の為当初問題が起きていたリポジトリにも同様の変更を行って確かめます。

class PlatformProfile < ApplicationRecord
acts_as_paranoid # こいつを追加
end

問題の起こっていたモデルを論理削除に変えて...問題が解消されました!

長々と調査過程を書きましたがこれで晴れて問題解決です。

メンタルモデルを新たにする

さて、思いのほか記事が長くなりましたがこれが最後のトピックです。この章ではここまでの話をもとに自分の中で形成された Ruby on Rails に関する1つのメンタルモデルの話をします。

今までの自分は after_destroy 内で attributes が更新できないことを一つの知識的なメンタルモデルとして持っている状態でした。

しかし "なぜafter_destroy 内で attributes を更新すると FrozenError で失敗するのか?" を整理したり、論理削除を行うケースで例外パターンが存在することを認識したりしたことで、新たに別のメンタルモデルを形成しました。

変更を永続化できない状態ということは @attributes は freeze されている

このようなメンタルモデルを自分の中に持ったことで今後、論理削除時に after_destroy で attributes を更新できることに迷ったりせず、他の類似ケースでも同様の考慮ができるように思います。

また今回の件で、普段から技術的な仕様や機能を点で捉えず多面的な視点で見ることによってより活用可能なメンタルモデルを培っていくことは重要だなと再認識できました。

おわり

メインのネタだけでなく直接関係ないことまでいろいろ書きました。たまにはこういう記事もいいですね。

参考

このストーリーが気になったら、遊びに来てみませんか?
未来のWantedlyを牽引するインフラや技術基盤を一緒に作りませんか?
Wantedly, Inc.では一緒に働く仲間を募集しています
8 いいね!
8 いいね!

今週のランキング

市古 空さんにいいねを伝えよう
市古 空さんや会社があなたに興味を持つかも