🗂️

redis-objects を継承関係の親子で使うと保存先の key が変わることがある

2025/03/04に公開

こんにちは、simomu です。
今日は redis-objects gem を継承関係の親子で利用すると、Redis の保存先の key が変わってしまうことがある話をします。

以後断りの無い場合は

  • redis-objects 1.7.0

環境下での話とします。

redis-objects

redis-objects gem は Redis のデータ型を Ruby のオブジェクト ( e.g. String, Integer, Array...)にマップする機能と、Redis へのアクセスをモデルクラスに組み込むことができる機能を備えた gem です。ActiveRecord なモデルに組み込んで、モデルに関連付けたカウンターやリストなどを用意するのがよくある利用場面になると思います。

class User < ApplicationRecord
  include Redis::Objects

  counter :my_posts
end
User.find(1).my_posts.increment

継承関係の親子で使うと保存先の key が変わることがある

たとえば、以下のように Parent と Child が存在していて、親クラスと子クラスにそれぞれ redis-objects の counter や value を設定しているとします。

class Parent
  include Redis::Objects

  counter :counter

  def id = 1
end

class Child < Parent
  value :value

  def id = 1
end

この状況下で、例えば Child#value が利用する Redis の key を確認してみます。#key で Redis の保存先の key を取得することができ、"child:1:value" であることが確認できます。

child = Child.new
puts child.value.key
# => "child:1:value"

redis-objects gem を利用している時、Redis の key 名はデフォルトでは {クラス名}:{ID}:{redis object 名} になります。

ここで、親クラスで定義されている counter の key 名も確認してみると、これは "child:1:counter" と表示されます。

puts child.counter.key
# => "child:1:counter"

しかしながら、もう一度別の Ruby プロセスで同様に Redis の key を確認します。
今度は親クラスの counter → 子クラスの value の順番で key を確認してみます。

child = Child.new
puts child.counter.key
# => "parent:1:counter"
puts child.value.key
# => "parent:1:value"

すると、親クラスで定義されている counter も、子クラスで定義されている value も、両方とも key の prefix が parent になってしまいました。つまりは、親子で定義された redis object どちらを先に参照するかによって、子クラスで定義された redis object の保存先の key が全て変わってしまう ということが発生します。

これは、Child が別のインスタンスであったとしてもこの影響は残り続けます。
その Ruby プロセスが親と子のどちらの redis object を先に参照したかによって key が変わり、その Ruby プロセス中ではそのまま固定されます


## 毎回 Child.new して別インスタンスにしてみる

puts Child.new.counter.key
# => "parent:1:counter"
puts Child.new.value.key
# => "parent:1:value"

## --- 別 Ruby プロセス --- ##

puts Child.new.value.key
# => "child:1:value"
puts Child.new.counter.key
# => "child:1:counter"

これは、例えば Ruby on Rails において STI を利用してるモデルの親クラスに共通で使う redis object、子クラスには固有の redis object を定義するようなことをしていた場合に、Ruby on Rails が親子のどちらの redis object を先に参照するかによって Redis の保存先が変わってしまうことになります。Ruby on Rails のプロセスが複数存在していた場合は、プロセスによって Redis 保存した情報が取得出来たり出来なかったりすることが発生します。

原因

この事象の原因を探るために、redis-objects gem が Redis の key を決定する流れを軽く見てみます。ここでは counter を例に見てみます。

counter :counter のようにカウンターの redis object を定義するメソッドは lib/redis/objects/counters.rb に存在しています。

https://github.com/nateware/redis-objects/blob/v1.7.0/lib/redis/objects/counters.rb#L20-L37

25 行目で redis_objects[name.to_sym] に定義した redis object 情報を格納し、29 ~ 37 行目で Redis::Counter を返すメソッド [1] を定義しています。33 行目の redis_field_key(name) が、redis object 名に対応する Redis の key を返していそうです。

redis_field_keylib/redis/objects.rb に定義されていて、Redis::Objects を include した先のクラスの特異メソッドとして定義されます。

https://github.com/nateware/redis-objects/blob/v1.7.0/lib/redis/objects.rb#L129-L147

130 行目に first_ancestor_with メソッドを実行してます。このメソッドが Redis の key の prefix で使用するクラスを取得してきてそうです。

first_ancestor_with はそのすぐ下に定義されています。

https://github.com/nateware/redis-objects/blob/v1.7.0/lib/redis/objects.rb#L149-L155

ここでやっていることをざっくりまとめると、「今のクラスに指定された名前の redis object が存在していれば今のクラスを返し、なければ親クラスを同様に探索する」ということをやっているようです。

redis_object は内部ではクラスインスタンス変数として定義されているため、子クラスの redis_object には子クラスで定義された redis object の情報のみが格納されていて、親クラスの情報は見ることが出来ません。

https://github.com/nateware/redis-objects/blob/v1.7.0/lib/redis/objects.rb#L98-L102

こうして取得できた、「指定された redis object が定義されているクラス」を元に、redis_field_key メソッドは redis_prefix を通して Redis の key の prefix が決定されます。

https://github.com/nateware/redis-objects/blob/v1.7.0/lib/redis/objects.rb#L144-L146

redis_prefix は、渡されたクラスを元に決定された prefix をクラスインスタンス変数にメモ化する形で保持されます。

https://github.com/nateware/redis-objects/blob/v1.7.0/lib/redis/objects.rb#L106-L112

以上から、redis-objects が保存先の key を決定する流れを軽くまとめると

  1. redis object が定義さているクラスを探索
  2. 見つかったクラスを元に prefix を決定し、クラスインスタンス変数 @redix_prefix に保存
  3. 2回目以降は @redix_prefix をそのまま利用する

になります。
今回の事象は 1 で見つかるクラスが、redis object が定義されている場所によって ChildParent で変わってしまい、更にその結果が @redix_prefix にメモ化されるため、参照順によって key の prefix が変わった上で固定され続けることが発生します。

redis-objects 2.0.0-beta ではどうなのか

redis-objects 2.0.0-beta では、redis_prefix の作成方法に変更があり、クラス名に相当する箇所がクラス名のフルパスを利用するような変更が入っています。( e.g. Nested::Child -> nested__child )
https://github.com/nateware/redis-objects/issues/231

しかしながら、redix_prefix に使用するクラスの探索と、結果をメモ化する箇所は概ね変わっていないため、2025年2月時点のredis-objects 2.0.0-beta でも同様の問題は発生します。

対策

この事象は redis object が定義されたクラス名をもとに redix_prefix を決定することに起因します。そのため、redis object が定義されるクラスを統一するか、redix_prefix を上書き固定することで解決できそうです。

例えば、Class#inherited を用いて、Parent クラスが継承された際に child クラスに counter を定義することで解決できます。このようにすれば counter を定義するという振る舞いは Parent に記述しつつ、実際に counter が定義されるのは Parent のサブクラスの Child ということになり、参照する redis object によって redix_prefix を決定するクラスが変わることがなくなります。

class Parent
  include Redis::Objects

  def self.inherited(subclass)
    subclass.counter :counter
  end

  def id = 1
end

class Child < Parent
  value :value

  def id = 1
end

puts Child.new.counter.key
# => "child:1:counter"
puts Child.new.value.key
# => "child:1:value"

注意点としては、この方式を採用する場合は Parent クラスそのものには counter は定義されなくなるので、Parent 単体では利用することはできなくなり、抽象クラスのような扱いになります。

別の方法としては、redis-objects gem では、redis_prefix を自前で設定することができるため、例えば子クラスの方で self.redix_prefix= を使い child に強制させることが出来ます。

class Parent
  include Redis::Objects

  counter :counter

  def id = 1
end

class Child < Parent
  include Redis::Objects
  self.redis_prefix = 'child'
  value :value

  def id = 1
end

puts Child.new.counter.key
# => "child:1:counter"
puts Child.new.value.key
# => "child:1:value"

このようにすれば、redis object の参照順にかかわらず key は "child:1:value" になります。

まとめ

今回は、redis-objects を継承関係の親子で使うと保存先の key が変わることがあるという事象についての原因と、現時点で取れる対策についての話をしました。
この事象については redis-objects 側の不具合なのかどうかは何とも言えず、もともと redis-objects 継承関係の親子でそれぞれ定義される使い方を想定されていない可能性もあります。このあたりは redis-objects の過去の issue 等での議論がないかをもう少し調べてみようと思います。

脚注
  1. 正確には、Redis::Counter を返すメソッドが定義されたモジュール ↩︎

SocialPLUS Tech Blog

Discussion