Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: フレームワークの機能をテストする価値がある場合(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

Rails: フレームワークの機能をテストする価値がある場合(翻訳)

Rails 6で導入されたupsert_allは、生SQLを使わずに多数のレコードを一括で挿入・更新するのに有用な方法です。以前のRailsには、この機能を提供するactiverecord-importなどのgemがあり、Rails Event Storeで大いに活躍しました。

RailsEventStore/rails_event_store - GitHub

Rails 6で不便だった点

しかしupsert_allには1つ小さな欠点がありました。タイムスタンプカラム(created_atupdated_at)が自動的に更新されず、データベースのNOT NULL制約によって挿入が失敗するのです。

そのため、以下のように手動で行わなければなりませんでした。

timestamp = Time.current

FancyModel.upsert_all([{ foo: :bar, created_at: timestamp, updated_at: timestamp }], unique_by: [:custom_unique_index])

これは新しいオブジェクトでは問題なく動くのですが、既存のオブジェクトが更新された場合はうまくいかないことがあります。この問題が判明したのは、私たちがシステムで問題を調査していたときでした。更新されたことが分かっているレコードでは、created_atupdated_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

成功しました。

ついに真相を解明

PostgreSQLCURRENT_TIMESTAMPはトランザクションの開始時刻を返すことが判明しました(私たちのケースではテストをラップするトランザクションを指します)。つまり、テスト内でupsert_allを実行した後ではcreated_atupdated_atの時刻が互いに別の値になる機会はありません。PostgreSQL 13のドキュメントには以下のように書かれています。

これらの関数は現在のトランザクションの開始時刻を返すため、その値はトランザクションが実行されている間は変化しません。 これは仕様であると考えられており、その意図は、単一のトランザクションが一貫性のある「現在」時刻の概念を持ち、同一トランザクション内の複数の変更が同一のタイムスタンプを持つようにすることにあります。

MySQLのNOW()関数も同様です。

RailsでCURRENT_TIMESTAMPがどのように使われているかを知りたい方は、一度Railsのコードベースを見てみてください。

お知らせ: 5600人以上のRailsエンジニアが購読しているメールマガジン

元記事末尾のフォームに登録いただくと、Arkencyのベテランプログラマーによる磨き抜かれた洞察や知恵を結晶したメールマガジンを配信いたします。

私たちは皆さんのメールボックスにスパムを決して送信することのないよう最大限に配慮しています。フォームを送信いただくと確認メールが届きます。

関連記事

Rails: 5年前のアドバイザリーロック実装が突然おかしくなった話(翻訳)


  1. マーティはバック・トゥ・ザ・フューチャーの主人公ですね。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。