🛤️

globalid gem のアップデートに失敗した話

2023/12/05に公開

TimeTreeアドベントカレンダーの5日目の記事になります。
昨日は Saul による TimeTreeウェブ版のSSR脱却と負債を生まない目標づくり でした。

入社して間がないにも関わらず大きい技術的負債に取り組んでいる仕事ぶりにはたくさん刺激をもらっています。物事を細かく整理して着実に前に進めていく丁寧さと根気強さ、自分も見習い続けたいです。

こんにちは、 TimeTree の SRE と Backend チームに所属している ta1kt0me です。社内ではGregというニックネームで仕事をしています。
最近はアプリケーションのDBの改善みたいなことをして、1歩進んで2歩下がるみたいなことを繰り返す1年でした。

今日は最近書いてしまった Monkey Patch について紹介します。

背景

2つの背景が今回の問題につながっています。

1. composite_primary_keys gem の利用

TimeTree の API は Ruby on Rails で作られていますが、歴史的経緯によりサービス当初より複合主キーを使っています。過去に複合主キーを使わずに一般的なサロゲートキーとなる id カラムへの移行も行われており、現在は極限られたテーブルでのみ複合主キーを利用しています。 この機能は composite_primary_keys という gem で実現しています。

https://github.com/composite-primary-keys/composite_primary_keys

composite_primary_keys では複合主キーの分割文字として , を利用します。

例えば、 post_id: 1, tag_id: 2 のような複合主キーを持つTagクラスのインスタンスをDBから取得する場合、以下のように記述できます。

Tag.find([1,2])
Tag.find("1,2")

今回アップデートの失敗に遭遇した globalid gem はアプリケーション全体で ActiveRecord のモデルのインスタンスを URI で表現する機能を提供しています。 ActiveJob などの機能で利用されています。

globalid の 1.1.0 までは post_id: 1, tag_id: 2 のような複合主キーを持つ Tag クラスのインスタンスに対して #to_global_id を呼ぶと gid://sample-app/Tag/1%2C2 という結果を返します。 , はURLエンコードされます。

2. Ruby on Rails 7.1 の複合主キーサポート

Ruby on Rails 7.1 から複合主キーがサポートされました。 globalid gem の 1.2.0 には複合主キーのオブジェクトのキーを表現するためにデフォルトで / を分割文字とする修正が含まれています。

https://github.com/rails/globalid/pull/163

globalid のバージョン 1.2.0 以上の場合、 #to_globall_id を呼ぶと gid://sample-app/Tag/1/2 という / 区切りの結果が返ります。

発生した事象

事象を一行で表すと、古いバージョンで作成した globalid を新しいバージョンでは正しく復元できないというものです。

Global ID に依存した独自の処理や新旧のバージョンの組み合わせによるテストを行なっていない限り、この変更による影響は事前に検知しづらいです。
両方のバージョンが一時的に混在するようなリリースのタイミングで問題が顕在化します。

私たちが遭遇した状況は、リリースタイミングで一時的に以下となった時でした。

  • ActiveJob を使って非同期ジョブを作成し、ジョブキューに詰めるタイミングでは古い globalid gem のバージョンで動作
  • 非同期ジョブを実行する環境は新しい globalid gem のバージョンで動作

client は古いバージョンの globalid gem を使っているため Job キューに gid://sample-app/Tag/1%2C2 という形式でpushします。

非同期ジョブを処理する worker は新しいバージョンの globalid gem を使っているため Job キューから pop した情報を復元するとき、 / 区切りの gid://sample-app/Tag/1/2 という形式を期待します。しかし , 区切りで設定されているため、複合主キーを復元できずにインスタンスを取得できない問題が発生します。

結果として、永遠に失敗し続ける非同期ジョブが発生しました。

参考までに問題が発生した 2023 年 11 月時点での私たちの環境は以下の通りでした。

  • Ruby 3.2.2
  • Ruby on Rails 7.0.8
  • composite_primary_keys 14.0.7
  • globalid 1.1.0 → 1.2.1

renovate による Lock file maintenance の Gemfile.lock の更新のアップデートが含まれたリリースで発生しました。

解決策の検討

非同期ジョブを生成する client とジョブを実行する worker で、古いバージョン old と新しいバージョン new の混在状況を整理すると、

client worker 結果
old old 問題なし
old new 問題あり
new old 一時的に問題あり
new new 問題なし

リリースタイミングで clientが new で worker が old の場合、エラーとなるジョブが発生しますが、リリースが進むにつれて worker も new になるため、最終的には正常に処理できます。

修正のパターンとしては、以下の2つが考えられます。

  • client が old でも / 区切りの形式の Global ID を生成する
  • worker が newでも , 区切りの形式の Global ID を処理可能にする

前者の場合、worker が new になるまでジョブキューのアイテム数は増加します。 old が全て処理されるまでリリースを待つ必要があるため、大量のジョブを処理している場合やリリースに時間がかかる場合はサービスへの影響も大きくなります。発生する問題を対処するために何かしら追加の対応が必要になるかもしれません。

後者の場合は、リリースのタイミングで一時的に new を処理できないエラーは発生しますが、新旧両方を処理できるようになると非同期ジョブのリトライにより自然と問題は解消していきます。。

globalid gem に対してこの振る舞いを行う修正がしやすいかという視点もあります。

Global ID をモデルのインスタンスの id に変換する処理は URI::GID#set_model_components という private メソッドが担っています。

https://github.com/rails/globalid/blob/v1.2.0/lib/global_id/uri/gid.rb#L154-L172

ざっくりとこんな感じです。

  • / で分割し、インスタンスのIDを特定する
  • インスタンスのIDを COMPOSITE_MODEL_ID_DELIMITER で分割し複合主キーを取得する

このメソッドをoverrideして , 区切りだったものを / 区切りに変換した上で元のメソッドを呼び出すことで解決できる見込みが立ちました。

この問題が恒常的に発生するものであれば、恒常的な対応として色々考えることも増えますがが、リリースの本当に一時的なタイミング、かつリリース後は問題が発生しないという状況を考慮して Monkey Patch による解決策を施すことにしました 🙈

解決

以下の修正を行い、新旧の両方の形式が混在していても処理できるようになりました。
その後、即座にこのパッチは削除しています。残し続けることは危険ですね…

# config/initializers/globalid_ext.rb
URI::GID.prepend(Module.new do
  private

  # Override https://github.com/rails/globalid/blob/v1.2.1/lib/global_id/uri/gid.rb#L154-L172
  def set_model_components(path, validate = false)
    if path.split("/", 3).last&.include?("%2C")
      path = path.gsub("%2C", "/")
    end

    super(path, validate)
  end
end)

参考として簡単ですがテストも含めた検証コードを掲載しておきます。

# frozen_string_literal: true
require "bundler/inline"

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

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

  # Activate the gem you are reporting the issue against.
  gem "activerecord", "7.0.8"
  gem "composite_primary_keys", "14.0.7"
  gem "sqlite3"
  gem "globalid", "1.2.1" # gid://sample-app/Tag/1/2
  # gem "globalid", "1.1.0" # gid://sample-app/Tag/1%2C2
end

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

# 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|
  end

  execute(
    <<~TABLE
      CREATE TABLE tags (
        post_id integer,
        tag_id  integer,
        PRIMARY KEY (post_id, tag_id)
      )
    TABLE
  )
end

GlobalID.app = "sample-app"

class Post < ActiveRecord::Base
end

class Tag < ActiveRecord::Base
  include GlobalID::Identification

  belongs_to :post

  self.primary_keys = :post_id, :tag_id
end

URI::GID.prepend(Module.new do
  private

  # Override https://github.com/rails/globalid/blob/v1.2.1/lib/global_id/uri/gid.rb#L154-L172
  def set_model_components(path, validate = false)
    if path.split("/", 3).last&.include?("%2C")
      path = path.gsub("%2C", "/")
    end

    super(path, validate)
  end
end)

class BugTest < Minitest::Test
  def test_patch
    post = Post.create!
    tag_one = Tag.create(post_id: post.id, tag_id: 1)
    tag_two = Tag.create(post_id: post.id, tag_id: 2)

    assert_equal 2, Tag.count

    gid = "gid://sample-app/Tag/1/1" # 1.2 CPKs format
    object = GlobalID::Locator.locate(gid)
    assert_equal object, tag_one

    gid = "gid://sample-app/Tag/1%2C2" # 1.1 CPKs format with composite_primary_keys gem
    object = GlobalID::Locator.locate(gid)
    assert_equal object, tag_two
  end
end

終わりに

globalid gem では元々複合主キーの扱いが未定義だったので、仕様を明確にしたことで発生した互換性の問題だと考えています。

複合主キーがサポートされない状況下で ActiveRecord の変更に追随し続けたcomposite_primary_keys gem にはとても感謝していますが、 TimeTree の API が Ruby on Rails 7.1 にアップデートできた暁には公式の複合主キーに乗り換える必要があると感じています。互換性の問題が解消されないようであれば 7.1 にアップデートするタイミングで乗り換える必要がありますが、どちらの道のりが厳しいのかはさてはて。

そもそも複合主キーを使わないで済む状況であれば Ruby on Rails をシンプルに使うことができるため、それが良い道の可能性もあります。最近はこういったアプリケーションデータ周りの改善が多いので、手軽に直せるものであれば直したいけれども!と優先度と向き合い続けています。来年も YATTEIKI です。

TimeTree Tech Blog

Discussion