globalid gem のアップデートに失敗した話
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 で実現しています。
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 には複合主キーのオブジェクトのキーを表現するためにデフォルトで /
を分割文字とする修正が含まれています。
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
メソッドが担っています。
ざっくりとこんな感じです。
-
/
で分割し、インスタンスの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のエンジニアによる記事です。メンバーのインタビューはこちらで発信中! note.com/timetree_inc/m/m4735531db852
Discussion