🔍

after_commitが正しく発火しない!

2021/12/11に公開

本記事は Sansan Advent Calendar 2021 11日目の記事です。

こんにちは。SansanのEightでサーバーサイドエンジニアをしています、平石です。
Eightでは名刺情報を活用し展開しているプロフェッショナルリクルーティングプラットフォームEight Career Designの開発をしています。

今回の話はそのサービスのコアとなっている候補者検索機能のデータ同期の開発をしている際に起きたお話です。

背景

Elasticsearchを候補者検索機能で用いており、MySQLのデータベース上の情報と同期をしています。MySQL上のデータが作成・変更・削除された際にはafter_commitを使ってSQSにJobを積み、非同期でElasticsearch上のドキュメントの更新を行なっています。(詳しくはこちらの記事をご覧ください)

after_commitとは

after_commitはActiveRecordのコールバックの一種でレコードが保存・削除されるたびに、トランザクションがデータベースにコミットした後で呼び出されます。(after_saveはトランザクション内で呼ばれる)
なので、今回のようなElasticsearchの同期やメール送信なでの外部のシステムとやりとりを行いたいときに便利なコールバックになっています。

class User
  after_commit :enque_sync_user
  
  def enque_sync_user
    # 同期処理
  end
end

問題発生

実際に動作確認をしていると、after_commitをしても正しいデータが同期されずにElasticsearchのindexに間違ったデータが同期されてしまうことがわかりました。

class User  
  after_commit :enque_sync_user
  
  def enque_sync_user
    # 同期処理
  end
end

ActiveRecord.transaction do
  user1 = User.find_by(id: 1)
  user2 = User.find_by(id: 1)
  
  user1.update(name: user1)
  user2.update(name: user2)
end

原因

色々調べて行って、あるIssueに辿り着きました

https://github.com/rails/rails/issues/39400
https://github.com/rails/rails/issues/39714

同じトランザクション内で複数の異なるインスタンスを生成し別々に同じレコードに対して更新を行うと先に更新した方でafter_commitが発火してしまい、後に更新した方では発火しません。

すなわち、今回の場合だとuser1がupdateした時にしかafter_commitが走らずuserのnameを同期しようとした際にnameをインスタンス変数として用いてデータを同期するとuser2の方のupdateが走り終わった後ではなく、user1としてElasticsearchに同期されてしまった。

これ自体はActiveRecordのバグとして残っているようなものみたいです。

結論

after_commit_everywhereを使う
https://github.com/Envek/after_commit_everywhere

gemの説明に

Allows to use ActiveRecord transactional callbacks outside of ActiveRecord models, literally everywhere in your application.

と書いてある通り、Transactionのコールバックをあらゆる場所で使用できるようになるgemです。もう少し言うとTransaction内で意図した挙動をafter_saveで登録しておいて、transactionが終わった後にafter_commitが実行できるというようなものです。

class User
  after_save :enque_sync_user

  def enque_sync_user
    AfterCommitEverywhere.after_commit do
      # 同期処理
    end
  end
end

違う方法としては、SQSに積んでいたJobの積み先をデータベースに格納できるようにする方法が考えられる。そうすることで同一トランザクション内でデータベースにジョブを積むことができるため、コミットとJobのタイミングは一致するようになる。一方で、基本的にプロダクトとしてデータベースに対して負荷をあげたくなく、開発の工数としてもそこそこ増えるためにこのようなgemが使えるのはありがたいことです。

Discussion