Rails でどこでも after_commit したい話

この記事は裏freee developers Advent Calendar 2018の16日目の記事です。 裏感なく普通にtips的な内容です。


ありがち?なバグ

かなり大雑把に表現すると↓のようなコードがありました。 do_something1 で新しく Model を作ったり更新したりしたあと、最終的な状態に基づいてメールを送信するという具合です(記事中の Model という言葉は ActiveRecord で表現される app/models 以下のやつらを指します)。 send_email は内部で ResqueMailer に処理を投げています。 do_something1 の部分で操作した Model をidベースで Mailer に渡し、Mailer 側ではidを使って取得するようになっています。

1
2
3
4
5
6
7
8
9
10
11
class Hoge
def exec
ActiveRecord::Base.transaction do
do_something1
send_email(model.id)
do_something2
end
end
end

Hoge.new.exec

do_something2 の処理が完了する前(=トランザクションがコミットされる前)に Mailer の処理が動いてしまうと、古い状態の Model が見えてしまったり、存在しない Model を取得しようとしてエラーになってしまいます。

ちょいちょい発生していたエラーを追っていたら、以上のような構造になっていました。

シュっと対応

トランザクションがコミットされる前に Resque 経由で Mailer が動いてしまうことが問題なので Resque に積むタイミングをトランザクションがコミットされた後にすれば解決します。(この例の場合なら do_something2 の位置をずらせばほぼ解決ですが)

1
2
3
4
5
6
7
8
9
10
11
12
class Hoge
def exec
ActiveRecord::Base.transaction do
do_something1
do_something2
end

send_email(model.id)
end
end

Hoge.new.exec

めでたしめでたし……?

ダメじゃん

これではダメです。 次のように ActiveRecord::Base.transaction がネストしているケースで問題が発生します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Hoge
def exec
ActiveRecord::Base.transaction do
do_something1
do_something2
end

send_email(model.id)
end
end

class Fuga
def exec
ActiveRecord::Base.transaction do
do_fuga1
Hoge.new.exec
do_fuga2
end
end
end

Fuga.new.exec

実際のDBのトランザクションは Fuga 側のブロックの終わりにコミットされるため、トランザクションの外に追いやったはずの send_email がトランザクションの中残ったままになってしまっています。

after_commit が使えれば良いのですが、 after_commit は ActiveRecord のコールバックなので Model 以外では使えません。 Perl を書いていたときに使った DBIx::TransactionManager::EndHook を思い出し、それっぽいのものがないのかを探しました。

after_commit_everywhere

この記事を見つけ、そのコメント中で宣伝されていた after_commit_everywhere という Gem がインタフェース的にまさに欲しかったものでした。 中身を読んでみたところかなりコンパクトで、いざとなったらまるまるメンテできそうで安心したので採用しました。

after_commit_everywhere を使用して書き直すと以下のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Hoge
include AfterCommitEverywhere

def exec
ActiveRecord::Base.transaction do
do_something1

after_commit do
send_email(model.id)
end

do_something2
end
end
end

class Fuga
def exec
ActiveRecord::Base.transaction do
do_fuga1
Hoge.new.exec
do_fuga2
end
end
end

Fuga.new.exec

これで Hoge が意図せずトランザクションの中で実行されたとしても send_email はコミットされた後に実行され、 Mailer が処理を行う時点で最新の Model を取得できるようになりました。

終わりに

多くのケースでは ActiveRecord のコールバックを普通に使えば十分ですが、たまに ActiveRecord ではない部分でもコミットをトリガーとして処理を実行したくなるかと思います。 使いすぎると可読性がヤバいことになりそうなのでご利用は計画的に。


明日17日はyo_wakaによる新作発表らしいです。お楽しみに。