after_commitが正しく発火しない!
本記事は 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に辿り着きました
同じトランザクション内で複数の異なるインスタンスを生成し別々に同じレコードに対して更新を行うと先に更新した方でafter_commitが発火してしまい、後に更新した方では発火しません。
すなわち、今回の場合だとuser1がupdateした時にしかafter_commitが走らずuserのnameを同期しようとした際にnameをインスタンス変数として用いてデータを同期するとuser2の方のupdateが走り終わった後ではなく、user1としてElasticsearchに同期されてしまった。
これ自体はActiveRecordのバグとして残っているようなものみたいです。
結論
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