blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2023.12.15 技術記事

MysqlRewinder という gem を作った

by Yusuke Sangenya

#Ruby #MySQL #Ruby on Rails

はじめに

ソリューション事業本部の三軒家です。 先日、社内のとある Rails プロダクトで利用する gem を開発し、OSSとして公開したので、その話をします。

この記事は Ruby on Rails Advent Calendar 2023 の15日目の記事です。

gem のレポジトリ

gem の概要

database_rewinder という gem があります。 これを使うとテストケースを実行するたびに DB の中身が初期化されて、しかも超速いというすごい gem です。 弊社でもヘビーユースさせていただいていたのですが、あるプロダクトの自動テストにおいて適切にデータが初期化されないケースがあり、 Flaky test の原因となっていました。

今回ご紹介する mysql_rewinder は、この問題を解決するために database_rewinder の代替を目指して開発した gem です。

使用例

Ruby on Rails / RSpec と組み合わせる場合、以下のように利用します。

RSpec.configure do |config|
  # MysqlRewinder の初期設定
  config.before(:suite) do
    db_configs = ActiveRecord::Base.configurations.configs_for(env_name: 'test').map(&:configuration_hash)
    except_tables = [ActiveRecord::Base.internal_metadata_table_name, ActiveRecord::SchemaMigration.table_name]
    
    MysqlRewinder.setup(db_configs, except_tables: except_tables)
    MysqlRewinder.clean_all
  end

  # 各 example を実行したあとに、example 内で作成されたレコードを削除する
  config.after(:each) do
    MysqlRewinder.clean
  end
end

mysql_rewinder は mysql2 もしくは trilogy にのみ依存し、Ruby on Rails や RSpec がなくても動きます。 逆に、 database_rewinder と違い、PostgreSQL や SQLite を利用している場合は動作しません。

詳しい使い方については README を参照してください。

どうやって動いているのか

mysql_rewinder は、以下の設計に基づいて動作します。

  • Mysql2::Client#query を prepend して、テスト中に実行されたクエリをすべてキャプチャする1
    • mysql2 経由で実行されたクエリは全て Mysql2::Client#query を通過するので、全てのクエリをキャプチャすることができます
  • キャプチャしたクエリから INSERT の対象となったテーブル名を抽出し、一時ファイルにテーブル名を書き出しておく
  • MysqlRewinder.clean が呼ばれたタイミングで一時ファイルを集計し、書き込みが発生したテーブルに対して DELETE クエリを実行する

database_rewinder と同様に、INSERT の対象となったテーブルに対してのみ DELETE クエリを発行するため、高速に動作します。 手元の大規模 Rails プロジェクトでは、database_rewinder と同等の速度が出ることを確認しています。

gem 開発の経緯

この gem を開発するに至った経緯について説明します。

Rails プロダクトの基盤整備

私が開発している大規模 Rails プロダクトでは、ここ一年ほど基盤周りの刷新を行っていました。 例えば以下のような施策を実施しています。

  • Ruby on Rails と Ruby のアップデート
    • Ruby on Rails: 5.2 → 7.0
    • Ruby: 2.6 → 3.0
    • 合わせて各種 gem のアップデート(ほぼ全ての gem を最新に上げた)
  • CI 基盤の刷新
    • Jenkins → Github Actions
    • 外部サービスに関してスタブを利用せずにテストできる仕組みの構築
    • 各種 Flaky test の撲滅活動
    • テスト実行の並列化(によるCI待ち時間の短縮)

その中でも、最後の「テスト実行の並列化」を実施する中で、特定のテストケースにおいてDB上のデータが一部初期化されず、その結果として後続のテストが落ちるケースが観測されました。

データが初期化されない原因

原因を調査したところ、fork されたプロセスの中で INSERT されたデータ2を、database_rewinder が削除できないケースがあることがわかりました。 これは database_rewinder は「変更が加えられたテーブル一覧」をメモリ上に保持し、それを DELETE の対象としていることに起因します3

元々はテストの実行順が固定だったためこの問題が顕在化しなかったのですが、テスト実行を並列化する際に、合わせて実行順序のランダム化を実施したため、問題が目立つようになったということでした。

対応方法の検討

この問題を解決するために、database_rewinder に対してモンキーパッチを当ててみたり、修正を作って contribute しようとしたりしていたのですが、いずれにせよコードを大幅に書き換えない限り修正が難しいそうという印象を受けました。また、database_rewinder は Rails の内部実装に強く依存しているため、Rails のバージョンが変わると壊れがちという構造上の問題を抱えており、例えば 2023/12/11 時点においては Rails 7.1 との組み合わせで正常に動作しないことが知られています4

これらの状況を鑑み、いっそのこと新しい gem を作って差し替えてしまえと思い、開発したのが前述の mysql_rewinder です。

「変更が加えられたテーブル一覧」をメモリではなくファイルに保持することで、fork を伴うテストケースに対しても正しく動作するようになりました。 また、対応する DB を MySQL に限定することにより、シンプルな実装にすることができました5し、他の gem の内部実装への依存も、安定したインターフェイスに対する最小限の依存6だけなので、gem やフレームワークのアップデートに対して頑健であると考えています。

mysql_rewinder 導入の結果

私が開発しているプロダクトでは、database_rewinder を mysql_rewinder に差し替えたことで、fork 先での INSERT に起因する Flaky test は根絶されました。 gem の差し替えによってテストが落ちるなどの問題もなく、私が開発しているプロジェクトにおいては database_rewinder と遜色ない速度が出ていることを確認しています。

また、嬉しい副作用として、 Q4M の cleanup が不要になったということが挙げられます。 Q4M は MySQL 上で動作するジョブキューですが、ActiveRecord を利用しない7ため、database_rewinder は cleanup を実行することができませんでした。 一方で、mysql_rewinder は mysql2 や trilogy 経由で実行されたクエリをすべて認識するため、Q4M の cleanup を実行することができます。 そのため、これまで自前で実装していた Q4M に対する rewinder の実装を捨てることができました。

mysql_rewinder の現状

とはいえ、mysql_rewinder は弊社内のあるプロダクトで正常に動作することしか確認されておらず、他の Rails プロジェクトにおいては実行速度が低下したり、正しくデータを削除できないケースが存在するかもしれません。

興味を持たれた方はぜひ利用していただき、もし何か問題が見つかったら issue を立てていただけると大変嬉しいです。

まとめ

社内のとある Rails 製プロダクトの基盤を整備する中で datatabase_rewinder の抱える問題に直面しました。

その問題を解決する gem である mysql_rewinder を実装し、公開しました。

元々の目的であった Flaky test の撲滅は実現され、それに加えて嬉しい副作用もありました。

MySQL x Ruby でアプリケーションを作ってる人にはぜひご利用いただき、フィードバックをいただけると大変嬉しいです。


  1. なお、trilogy を利用する場合は Trilogy#query を prepend します ↩︎

  2. バッチ処理の内部で fork している箇所がある ↩︎

  3. fork によってメモリ空間が分離されるため、子プロセスで INSERT されたテーブルを親プロセスが認識できない ↩︎

  4. https://github.com/amatsuda/database_rewinder/issues/84 ↩︎

  5. 型定義ファイルを入れても500行ぐらい ↩︎

  6. Mysql2::Client#queryTrilogy#query の2つ ↩︎

  7. ryopeko/shinq を利用している ↩︎

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。