Rails: フレームワークの機能をテストする価値がある場合(翻訳)
Rails 6で導入されたupsert_all
は、生SQLを使わずに多数のレコードを一括で挿入・更新するのに有用な方法です。以前のRailsには、この機能を提供するactiverecord-importなどのgemがあり、Rails Event Storeで大いに活躍しました。
Rails 6で不便だった点
しかしupsert_all
には1つ小さな欠点がありました。タイムスタンプカラム(created_at
とupdated_at
)が自動的に更新されず、データベースのNOT NULL
制約によって挿入が失敗するのです。
そのため、以下のように手動で行わなければなりませんでした。
timestamp = Time.current
FancyModel.upsert_all([{ foo: :bar, created_at: timestamp, updated_at: timestamp }], unique_by: [:custom_unique_index])
これは新しいオブジェクトでは問題なく動くのですが、既存のオブジェクトが更新された場合はうまくいかないことがあります。この問題が判明したのは、私たちがシステムで問題を調査していたときでした。更新されたことが分かっているレコードでは、created_at
とupdated_at
が等しかったのです。
この問題を修正したかったので、最初に以下のテストを書きました。
class FancyModelTest < ActiveSupport::TestCase
def test_timestampz
FancyModel.create!(foo: :bar)
timestamp = Time.current
FancyModel.upsert_all(
[{ foo: :baz, created_at: timestamp, updated_at: timestamp }],
unique_by: [:custom_unique_index],
)
fancy = FancyModel.find_by!(foo: :baz)
assert(fancy.updated_at > fancy.created_at)
end
end
これは明らかに失敗します。
Rails 7に救われた...けど
この問題の修正案はいくつかありましたが、私たちは既にRails 7を使っていたので、最も手軽な解決方法が手の届くところにあったのです。Rails 7のupsert_all
はタイムスタンプを扱えます(その機能を無効にしない限り)。
両方のカラムに同一のタイムスタンプを設定するダメコードは削除され、Active Recordがタイムスタンプを処理するようになりました。しかしテストは毎回失敗してしまいます。
class FancyModelTest < ActiveSupport::TestCase
def test_timestampz
FancyModel.create!(foo: :bar)
FancyModel.upsert_all([{ foo: :baz }], unique_by: [:custom_unique_index])
fancy = FancyModel.find_by!(foo: :baz)
assert fancy.updated_at > fancy.created_at
end
end
処理が速すぎる?
もしかすると処理が速すぎてアサーションが気づく暇もなかったのではないかと思いつきました。
そこでパスさせるためにsleep(1)
を追加してみました。
class FancyModelTest < ActiveSupport::TestCase
def test_timestampz
FancyModel.create!(foo: :bar)
sleep(1)
FancyModel.upsert_all([{ foo: :baz }], unique_by: [:custom_unique_index])
fancy = FancyModel.find_by!(foo: :baz)
assert(fancy.updated_at > fancy.created_at)
end
end
だめです、パスしません。何が起きているのでしょう?
"タイムトラベルを試しちゃどうだ、マーティ?"1
過去の時刻でレコードを作成してみましょう。きっとうまくいくはずです。
class FancyModelTest < ActiveSupport::TestCase
def test_timestampz
travel_to Time.zone.local(1985, 10, 26, 1, 24) do
FancyModel.create!(foo: :bar)
end
FancyModel.upsert_all([{ foo: :baz }], unique_by: [:custom_unique_index])
fancy = FancyModel.find_by!(foo: :baz)
assert(fancy.updated_at > fancy.created_at)
end
end
見事に失敗しました。
頭をかきむしり、このときばかりは自分のスキルが信じられなくなりました。
トランザクショナルなテスト
Railsのコードをさんざん掘り返した結果、updated_at
に別の値が設定されないのは、テストがデータベーストランザクションでラップされていることと何か共通点があるのではないかという直感が走りました。トランザクションはテストケースの最後でロールバックするので、他のすべてのテストが互いに独立するようになります。
この仮説を証明するために、トランザクションを使わないサンプルコードを別途作成しました。
class FancyModelTest < ActiveSupport::TestCase
self.use_transactional_tests = false
def test_timestampz
FancyModel.create!(foo: :bar)
FancyModel.upsert_all([{ foo: :baz }], unique_by: [:custom_unique_index])
fancy = FancyModel.find_by!(foo: :baz)
assert(fancy.updated_at > fancy.created_at)
end
end
成功しました。
ついに真相を解明
PostgreSQLのCURRENT_TIMESTAMP
はトランザクションの開始時刻を返すことが判明しました(私たちのケースではテストをラップするトランザクションを指します)。つまり、テスト内でupsert_all
を実行した後ではcreated_at
とupdated_at
の時刻が互いに別の値になる機会はありません。PostgreSQL 13のドキュメントには以下のように書かれています。
これらの関数は現在のトランザクションの開始時刻を返すため、その値はトランザクションが実行されている間は変化しません。 これは仕様であると考えられており、その意図は、単一のトランザクションが一貫性のある「現在」時刻の概念を持ち、同一トランザクション内の複数の変更が同一のタイムスタンプを持つようにすることにあります。
MySQLのNOW()
関数も同様です。
RailsでCURRENT_TIMESTAMP
がどのように使われているかを知りたい方は、一度Railsのコードベースを見てみてください。
お知らせ: 5600人以上のRailsエンジニアが購読しているメールマガジン
元記事末尾のフォームに登録いただくと、Arkencyのベテランプログラマーによる磨き抜かれた洞察や知恵を結晶したメールマガジンを配信いたします。
私たちは皆さんのメールボックスにスパムを決して送信することのないよう最大限に配慮しています。フォームを送信いただくと確認メールが届きます。
関連記事
- マーティはバック・トゥ・ザ・フューチャーの主人公ですね。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。