Rails.cache.fetch で有効期限が効かずにキャッシュが更新されない

先日、Rails.cache.fetchを使っていた時、有効期限を短くしてるにもかかわらず古い値が残り続けるという現象が起きました。

結論から書くと、

間違えたコード

Rails.cache.fetchexpired_inは5秒と設定し、0をキャッシュさせます。 有効期限を5秒と設定しているので、5秒後にはこのキャッシュは消えているはずです。

number = Rails.cache.fetch('key', expired_in: 5.seconds) do
  0
end

=> 0

しかし、5秒後に中の値を変更して実行してみるとキャッシュが残り続けていました。

number = Rails.cache.fetch('key', expired_in: 5.seconds) do
  1
end

=> 0

Railsのソースコード調査

初めてこの現象に遭遇したのでRailsのコードを読んでみることにしました。

Rails.cacheに関するコードはactivesupport/lib/active_support/cache.rbにありました。

fetchメソッドは347行目にあります。

      def fetch(name, options = nil, &block)
        if block_given?
          options = merged_options(options)
          key = normalize_key(name, options)

          entry = nil
          instrument(:read, name, options) do |payload|
            cached_entry = read_entry(key, **options, event: payload) unless options[:force]
            entry = handle_expired_entry(cached_entry, key, options)
            entry = nil if entry && entry.mismatched?(normalize_version(name, options))
            payload[:super_operation] = :fetch if payload
            payload[:hit] = !!entry if payload
          end

          if entry
            get_entry_value(entry, name, options)
          else
            save_block_result_to_cache(name, options, &block)
          end
        elsif options && options[:force]
          raise ArgumentError, "Missing block: Calling `Cache#fetch` with `force: true` requires a block."
        else
          read(name, options)
        end
      end

get_entry_value(entry, name, options)でentryからキャッシュされた値を取り出しているみたいですね。 Entryの実装を見に行きましょう。

906行目あたりにあります。

expired?メソッドの実装を見ると当然ではありますが、きちんと計算されていそうです。 もしかすると保存先をRedisにしてるからRedisの内部でキャッシュが解放されないからかもと、一瞬頭をよぎったけどそんなわけはなかった。

      def expired?
        @expires_in && @created_at + @expires_in <= Time.now.to_f
      end

余談ですが、Timefloatへ変換してから比較してるの、便利そうですね。

ここでinitializeメソッドをみてこの現象が起きていた理由を理解しました。

解決方法

Entryクラスのinitializeメソッドを見ると引数に指定できるのはexpires_inまたはexpires_atとなっています。

そう、expired_inという引数は存在せず指定できなかったのです。僕が参考にしたサイトが間違っていたのが原因というオチでした。 

    class Entry # :nodoc:
      class << self
        def unpack(members)
          new(members[0], expires_at: members[1], version: members[2])
        end
      end

      attr_reader :version

      # Creates a new cache entry for the specified value. Options supported are
      # +:compressed+, +:version+, +:expires_at+ and +:expires_in+.
      def initialize(value, compressed: false, version: nil, expires_in: nil, expires_at: nil, **)
        @value      = value
        @version    = version
        @created_at = 0.0
        @expires_in = expires_at&.to_f || expires_in && (expires_in.to_f + Time.now.to_f)
        @compressed = true if compressed
      end

expires_inexpires_atnilでもエラーにならないので、まあそうなるなという挙動ですね。

まとめ

しょうもないミスにがっかりでしたが、Rails自体のコードを読んだこと自体はいい勉強になってよかったです。