アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

ActiveRecord::Base#reload はインスタンス変数をクリアしない

komatsu(@nomnel)です。
小ネタですがタイトルの事象でハマってしまったので記事にします。

どういうこと?

下のコードで説明します。

irb(main):001:0> foo = Foo.first
=> #<Foo id: 1, name: "foo", created_at: "2018-08-31 06:25:39", updated_at: "2018-08-31 06:25:39">
irb(main):002:0> foo.name = "#{foo.name}_bar"
=> "foo_bar"
irb(main):003:0> foo.instance_variable_get(:@bar)
=> nil
irb(main):004:0> foo.instance_variable_set(:@bar, 1)
=> 1
irb(main):005:0> foo.instance_variable_get(:@bar)
=> 1
irb(main):006:0> foo.reload
=> #<Foo id: 1, name: "foo", created_at: "2018-08-31 06:25:39", updated_at: "2018-08-31 06:25:39">
irb(main):007:0> foo.name
=> "foo"
irb(main):008:0> foo.instance_variable_get(:@bar)
=> 1

irb(main):002 で 変更した foo.namefoo.reload で変更が無かったことになるのに、 irb(main):004 でセットしたインスタンス変数 @barfoo.reload 後もそのままの値を持ち続けるので注意しましょう…ということです。

今回は既存コードを下のようにインスタンス変数でメモ化して高速化を図ったところ、意図しないところでテストが落ちてしまいました。*1

# 変更前
def bar
  # 重い処理
end

# 変更後
def bar
  @bar ||= # 重い処理
end

(結局 foo.reload ではなく foo = foo.class.find(foo.id) のように再取得して上書きました)

実装を追う

同じようなハマりを繰り返さないように ActiveRecord::Base#reload の実装を確認しておきます。
新規に Rails 5.2.1 のアプリケーションを用意し、

# app/models/foo.rb
class Foo < ApplicationRecord
  def reload
    byebug
    super
  end
end

byebug でステップ実行しながら実装を追いました。

def reload(*) # :nodoc:
  clear_aggregation_cache
  super
end

https://github.com/rails/rails/blob/v5.2.1/activerecord/lib/active_record/aggregations.rb#L13

def reload(options = nil)
  @marked_for_destruction = false
  @destroyed_by_association = nil
  super
end

https://github.com/rails/rails/blob/v5.2.1/activerecord/lib/active_record/autosave_association.rb#L228

def reload(*) # :nodoc:
  clear_association_cache
  super
end

https://github.com/rails/rails/blob/v5.2.1/activerecord/lib/active_record/associations.rb#L253

# <tt>reload</tt> the record and clears changed attributes.
def reload(*)
  super.tap do
    @previously_changed = ActiveSupport::HashWithIndifferentAccess.new
    @mutations_before_last_save = nil
    @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
    @mutations_from_database = nil
  end
end

https://github.com/rails/rails/blob/v5.2.1/activerecord/lib/active_record/attribute_methods/dirty.rb#L33

def reload(options = nil)
  self.class.connection.clear_query_cache

  fresh_object =
    if options && options[:lock]
      self.class.unscoped { self.class.lock(options[:lock]).find(id) }
    else
      self.class.unscoped { self.class.find(id) }
    end

  @attributes = fresh_object.instance_variable_get("@attributes")
  @new_record = false
  self
end

https://github.com/rails/rails/blob/v5.2.1/activerecord/lib/active_record/persistence.rb#L601

(ちなみに ActiveRecord::Persistence は、ここで ActiveRecord::Base に include されています)

というわけで、ActiveRecord::Base#reload では

  • キャッシュを破棄したり各種のインスタンス変数を初期化(っぽい)したりしつつ、 @attributes を置き換えている
  • メモ化用のものなど、自前(?)でセットしたインスタンス変数達は変更されない

ということがわかりました。

共有は以上です。

*1:本来は別インスタンスが行う処理を1つのインスタンスを使い回して行っていた