Ruby-GetText 1.91.0 のキャッシュに関するバグの対処法

Ruby-GetText 1.92.0 ではすでに修正されています

現在

という環境のパッケージアプリケーションを作ってます。

そこで 『適当に操作してるとたまに日本語化されていないページが表示される』 というバグがあがってきたのですが、それを直したときの苦労話をメモとして残しておきます。

GetText まわりで何かおかしいのは予想がつきますが、そもそもそのバグがなかなか再現できず*1GetText のせいなのか、Windows のせいなのか、リリースまで1週間を切っているのに無理やり Rails 2.1 に上げたせいなのかわからず、最初は同じような報告が無いか(脱線しつつ)検索しただけで半日がつぶれました。

いよいよ本格的に向き合わなきゃダメそうだと思い Ruby-GetText を読み始めたのですが Windows 環境での debugger の動かし方が良くわからず、print debug を仕込んでは、アクセスを繰り返すスクリプトを立ち上げて再現するまでボーっとするを繰り返した結果、gettext.rb の次の部分に狙いを定めました。

module GetText
...
  def bound_target(klass = self) # :nodoc:
    id = klass.object_id
    if cached?
     tgt = @@__cache_bound_target[id]
     return tgt if tgt
    end

    ret = (klass.kind_of? Module) ? klass : klass.class
    if ret.name =~ /^\#<|^$/ or ret == GetText
       #GC for dead object_ids.
       ret = Object
       if @@__cache_bound_target.size > CACHE_BOUND_TARGET_MAX_SIZE
          @@__cache_bound_target.clear
       end
    end
    @@__cache_bound_target[id] = ret
    ret
  end

ここでは self.object_id を key に返り値のクラス*2をキャッシュしています。しかし object_id は再利用されるため、同じ object_id が割り当てられた違うクラスのインスタンスで再度呼び出された場合意図しない値が返ります。*3
もしこの現象が起きたとしても、間違って返ってきたクラスの textdomain が同じならば、問題は浮かび上がってこないようです。普通ひとつのアプリケーションで textdomain なんて複数つかわねーよと思うかもしれませんが、RailsGetText を利用する場合は "rails" という textdomain が GetText 内部で使用されており、キャッシュによって運悪く ActiveRecord::Errors とかが返ってきたときバグが再現するというのが真相だったようです。
また後半部分では、『何らかの条件』を満たした場合に @@__cache_bound_target.size が 50000 ( == CACHE_BOUND_TARGET_MAX_SIZE ) を越えている場合はキャッシュをクリアするという処理なのですが、この『何らかの条件』部分がいつ true になるのかわからず、観測上は一度もキャッシュが削除されることはありませんでした。

この問題は Rails と組み合わせたときだけでなく Ruby-GetText 単体で使っても起こりえます。例えば次のソースを実行してみるとユニークな object_id が 4000 ~ 10000 くらいにまで達したときに停止しました。(foo.po では 'Hello, foo!' => 'Konnichiha, foo!'、bar.po では 'Hello, bar!' => 'Konnichiha, bar!' となっています)

#!/usr/bin/env ruby

require 'rubygems'
require 'gettext'

class Foo
  include GetText
  bindtextdomain('foo')
  def hello; _('Hello, foo!'); end
end

class Bar
  include GetText
  bindtextdomain('bar')
  def hello; _('Hello, bar!'); end
end

loop do
  foo = Foo.new.hello
  bar = Bar.new.hello
  unless foo == 'Konnichiha, foo!' && bar == 'Konnichiha, bar!'
    puts GetText.send(:class_variable_get, :@@__cache_bound_target).size
    break
  end
end

そしてこれを修正しようと試み以下のコードを書きました。これを require "gettext" 直後に実行されるようにしたところ、とりあえず問題は起こらなくなりました。
後半の特に ret.name =~ /^\#<|^$/ret = Object あたりが何を意味してるのかわからず、『/^#< / というのはインスタンスの文字列表現かなぁ』とか『hensu = Module.new で作った Module は name が空文字列らしい』とか思いを巡らせた結果、やっぱりよくわからないので『インスタンスがいっぱい渡ってくるときはキャッシュをクリアしたい!っていうコードを書き損ねたもの』だと自分に都合よく解釈しました。

module GetText
  silence_warnings { CACHE_BOUND_TARGET_MAX_SIZE = 50 }

  def bound_target(klass = self) # :nodoc:
    id = klass.object_id
    if cached?
     tgt = @@__cache_bound_target[id]
     return tgt if tgt
    end

    ret = (is_instance = !klass.kind_of?(Module)) ? klass.class : klass
    if is_instance or ret == GetText
       #GC for dead object_ids.
       #ret = Object
       if @@__cache_bound_target.size > CACHE_BOUND_TARGET_MAX_SIZE
          @@__cache_bound_target.clear
       end
    end
    @@__cache_bound_target[id] = ret
    ret
  end
end

Rails 内で使用したときは @@__cache_bound_target.size が 250 くらいですでに問題が発生し始めたため CACHE_BOUND_TARGET_MAX_SIZE は 50 にしています。
元々の意図からはかなり外れてしまっていると思いますが、とりあえず動くのでこれでがまんしています。


ここまで書いたついでに、Ruby-GetText がらみでもうひとつ。
Ruby-GetText 1.91.0 から ActionView::Helpers::FormBuilder#label がローカライズされるようになりましたが、ActionView::Helpers::FormHelper#label の方はローカライズされないため form_for が使えない場面で微妙な感じになります。
そんなときはこんな感じのコードを仕込めば FormBuilder#label と FormHelper#label の両方に対応できます。

module ActionView
  module Helpers 
    class InstanceTag
      def to_label_tag_with_gettext(text = nil, options = {})
        text ||= object.class.human_attribute_name(method_name)
        to_label_tag_without_gettext(text, options)
      end
      alias_method_chain :to_label_tag, :gettext

*1:数十アクセスで起こる環境もあれば、数千〜数万アクセスに1回くらいしか起こらない環境もある。後でわかったことだけど再現するこつがあった

*2:例えば FooController の action で呼び出された場合は返り値が FooController で klass(==self) がそのインスタンス

*3:ちなみに 1.90.0 では key に klass そのものが使用されており、(別の問題はあるのかもしれませんが)今回のバグは起こらないようです