読者です 読者をやめる 読者になる 読者になる

【Rails】after_createが発動するタイミングはいつでしょう?

MUGENUPの倉成です。 今回はRailsのCallbackであるafter_createafter_commitの処理順番を改めて確認し、処理順番を誤解していた事によって僕が遭遇した問題ついて記事を書こうと思います。

シチュエーション

ブログ記事の新規投稿があった時、購読者に対してメールを送信する。

ブログの投稿時に通知のメールを送信するような場合、記事の投稿に必要な最低限の処理のみを行い、購読者へのメール送信などリアルタイムな処理が必要でないものは非同期で処理することで、レスポンス速度を向上させることが出来ます。

f:id:mgnup:20140804095733j:plain

非同期処理を行うためのライブラリとしてはresquesidekiqdelayed_jobなど幾つかのものがありますが、弊社ではこの用途にresqueを使っているので、この記事では特にresqueを取り上げて説明します*1

実装

さて、上の要件の実装として、僕は以下のようなコードを書きました。

# app/models/article.rb
class Article < ActiveRecord::Base
  # 記事が作成されたらメール送信タスクをqueueに積む
  after_create do
    Resque.enqueue(SendMail, id)
  end
end
# app/workers/send_mail.rb
class SendMail
  @queue = :send_mail
  def self.perform(article_id)
    # 投稿された記事をDBから読み込む
    article = Article.find(article_id)
    # ブログの購読者にメールを送信
    # ....
    # ....
  end
end

self.perform(article_id)の第一引数article_idResque.enqueue(SendMail, id)の第二引数idと対応しており、 新規記事のIDが渡されることになります。

さて、実際にアプリケーションを立ち上げて実行してみるとself.perform内部で

article = Article.find(article_id)で稀にNoRecordFoundが発生する

という現象が起きていました。

after_createという名のとおり記事は既にcreateされていて、レコードは存在しているはずです。その証拠に既にidは与えられていますよね。

なぜでしょう。

after_createはDBへのコミット直前に実行される

不思議に思い、出力されたログを見てみると、ResqueからのSELECTBEGIN ~ COMMITの内部で実行されていました。

DBはINSERTを実行しても、COMMITされるまでは、レコードは永続化されません。 つまりRailsがDBにCOMMITする前に、外部プロセスであるResqueのプロセスがSELECTをしていたため、NoRecordFoundが発生していまうという状態になっていました。

BEGIN
  INSERT INTO `articles` (`col1`, `col2`) VALUES ('..', '..')
  /* COMMIT前にResqueからのSELECTが発行される */
  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1
COMMIT

これは、after_createがDBへのコミット直前に実行されることが原因です*2

after_commitを使う

原因がわかりました。 after_createの代わりに、コミット直後に実行されるafter_commit on: :createを使いましょう。

これでCOMMITのあとにResqueの処理が実行されるため、NoRecordFoundが発生することもなくなりました。

BEGIN
  INSERT INTO `articles` (`col1`, `col2`) VALUES ('..', '..')
COMMIT
/* COMMIT後にResqueからのSELECTが発行される */
SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1

BEGIN ~ COMMITの外側でSELECTが実行されていることが、ログでも確認できます。

NoRecordFoundの原因は

稀にNoRecordFoundが発生する状態だったのは「COMMITの前に外部プロセスであるResqueのSELECTが発生するケースがあったため」というのが結論です。なお常に発生するでは無いのは、Resqueが新規ジョブをポーリングする間隔により、Resqueの処理がCOMMIT後に行われることもあったためです。

今回のようにRailscallbackをトリガーにして外部プロセスからDBにアクセスする場合はafter_commitを使うようにしましょう。

なお,同様の注意点は http://apidock.com/rails/ActiveRecord/Callbacks/after_createにも書いてありました*3

おわりに

今回は「after_createはDBにCOMMITされた後に呼び出される」という間違った認識によって遭遇した問題について記事を書かせていただきました。 ResqueなどRails外のプロセスを使う場合にこの記事を思い出していただけたら幸いです。

*1:今回は説明のため使用しませんがメール送信を非同期で行うならresque_mailerが便利です。

*2:コールバックの順番はこちらの記事にとても詳しくまとまっています http://techracho.bpsinc.jp/baba/2013_08_23/12670

*3:Rails 2.3.8のドキュメントですが,この注意点に関しては今のバージョンでも変わらずに使えると思います