RailsでTimeoutしてもロールバックされない?rubyライブラリのソースを読んで解決!
こんにちは、brownieユニットに移動した堀口です。
今回はRubyのtimeoutメソッドを使った時にロールバックされなかった話です。
先に理由だけ書いちゃうとtimeoutメソッドが悪いというよりメソッド内でthrow~catchを使っているせいでした。
ここではthrow~catchを使うと何故ロールバックされないのか、またtimeoutメソッドでどう処理されているかを説明していきます。
やりたかったこと
とあるバッチで使用しているAPIがたまに処理が長くて30分以上経っても終わらない場合、タイムアウトさせてDB更新のロールバックすることにしました。
以下のような感じです。
DB更新
↓
API実行
↓
処理が長い場合、タイムアウト発生
↓
ロールバック
ということをしたかったんです...
タイムアウトした時にロールバックさせたい!
Rubyでタイムアウトするには
RubyでタイムアウトするにはTimeout.timeoutを使います。
書き方は以下の通りです。
Timeout.timeout(5) do # タイムアウトする時間(秒)
sleep 6
end
timeoutメソッドの引数で指定した時間を超えるとTimeout::Errorが発生します。
この例だとtimeoutメソッドの引数は5秒でsleep 6
を実行しているのでTimeout::Errorが発生します。
sleep 4
とかにすると正常に終了します。
仕様
今回は下記の仕様で例を実装しています。
Userテーブル
id | |
---|---|
1 | test@example.com |
実装する処理はUser.emailを更新した後にAPIを実行するものとします。このAPIは実行するとタイムアウトしてしまうことがあります。
タイムアウトした場合、User.emailはロールバックするようにします。
実装
上記の仕様で実装しようとすると以下の2パターンのどちらかで実装すると思います。
# パターン1
Timeout.timeout(5) do
User.transaction do
User.first.update(email: 'update@example.com') # DB更新
sleep 6 # APIの処理時間とします
end
end
# パターン2
User.transaction do
Timeout.timeout(5) do
User.first.update(email: 'update@example.com') # DB更新
sleep 6 # APIの処理時間とします
end
end
この2パターンではどちらかが上手く動きません。それはどちらでしょうか?
実行して確認してみましょう。
まずはパターン1
・・・
(0.2ms) BEGIN
SQL (0.3ms) UPDATE ~
(4.8ms) COMMIT
Timeout::Error: execution expired
・・・
次はパターン2
・・・
(0.1ms) BEGIN
・・・
(0.4ms) ROLLBACK
Timeout::Error: execution expired
・・・
パターン1、パターン2ともにtimeoutメソッド内でsleep 6
を使ったのでTimeout::Errorが発生していることが確認できます。
しかしパターン1ではロールバックされずにコミットされ、パターン2ではちゃんとロールバックされています。
この2つの違いは何かについて説明していきたいと思います。
throw~catchについて
さてパターン1とパターン2の違いの説明の前にちょっと寄り道です。
みなさんはRubyのthrow~catchは知っていますか?
パターン1とパターン2の違いを説明するにはthrow~catchを知らないと説明できないので知っている場合は飛ばしていただいて大丈夫です。
throw~catchは以下のように書きます。
catch Object do
throw Object
end
throwは基本何でも投げることができ、catchで指定されたオブジェクトをcatchします。
これだけ見ると何ができるかわからないと思いますが以下のコードを見てください。
catch(:test) do
puts 'ループスタート'
while true
puts 'スロー前' # ここは実行される
while true
throw :test, "ループから抜ける"
end
puts 'スロー後' # ここは実行されない
end
end
詳しい説明の前に実行してみます。
ループスタート
スロー前
=> "ループから抜ける"
実行するとこのように表示されます。
ぱっと見「スロー前」と「スロー後」が永遠と出力される実装に見えますが実際には出力されていません。
何故かというと6行目でthrowしているためです。throwするとそのタイミングでループ等から抜けるためそれ以降は実行されません。
上記のソースだとputs 'スロー前'
はthrow前なので一度実行され、その後throwして全てのwhileから抜けるためputs 'スロー後'
が実行されることはありません。
このようにthrow~catchを使うとthrowしたタイミングでどんな処理からも抜けることができます。このことを『大域脱出』と呼びます。
2重ループ等から簡単に抜けることができるのでたまに役立つかもしれないと思うかもしれませんが、throw~catchはtransactionと組み合わせるとちょっと困ることがあります。
例えば以下のようなソースがあったとします。
catch(:test) do
User.transaction do
User.first.update(email: 'update@example.com')
throw :test, "スロー"
end
end
これを実行するとthrowしてtransactionを抜けるのでロールバックされません。
あまりないとは思いますがthrow~catchとtransactionのような組み合わせには気をつけてください。
Timeoutクラスのソース
throw~catchの説明をしたので話をパターン1とパターン2の話に戻します。
パターン1とパターン2で何が違うか、それはtimeoutメソッド内のthrow~catchの対象です。
Timeoutクラスのソースを見てみましょう。
timeoutメソッドの中身を確認してみます。
def timeout(sec, klass = nil, message = nil) #:yield: +sec+
return yield(sec) if sec == nil or sec.zero?
message ||= "execution expired".freeze
from = "from #{caller_locations(1, 1)[0]}" if $DEBUG
e = Error
bl = proc do |exception|
begin
x = Thread.current
y = Thread.start {
Thread.current.name = from
begin
sleep sec
rescue => e
x.raise e
else
x.raise exception, message
end
}
return yield(sec)
ensure
if y
y.kill
y.join # make sure y is dead.
end
end
end
if klass
begin
bl.call(klass)
rescue klass => e
bt = e.backtrace
end
else
bt = Error.catch(message, &bl)
end
level = -caller(CALLER_OFFSET).size-2
while THIS_FILE =~ bt[level]
bt.delete_at(level)
end
raise(e, message, bt)
end
2~5行目は関係ないので何か変数定義してるなーくらいでいいです。6行目でpropを作ってblに入れていますが、ここについては後で説明するので一旦置いておきます。27~32行目はklassの引数をtimeoutメソッド与えていないのでここについては説明しません。
というわけで34行目から説明していきます。Error.catch(message, &bl)
が実行されていてこのcatchメソッドはTimeout::Errorに実装されています(Timeoutクラスのソースのここです)。このcatchメソッドではyieldを実行しています。ここで実行されるyieldはError.catch(message, &bl)
のblになるのでblの実装を見ていきます。
まず2つのスレッドを用意しています(8~9行目)。スレッドxには現在のスレッド(timeoutメソッド内の処理)を入れ、スレッドyはタイムアウトまでの時間sleepするスレッドです。sleepが終了する前にスレッドxが終了すれば何もしませんが、もしsleepが終了した場合スレッドxに対してexceptionをthrowします(16行目)。見た目はraiseですがTimeout::Errorのexceptionメソッド内でexceptionをthrowしています(Timeoutクラスのソースのこのメソッドの処理)。ここで思い出して欲しいんですがthrowすると大域脱出します(処理が途中でもそれ以降の処理をしないで抜ける)。そのためtimeoutメソッド内でタイムアウト後の処理を書いていても実行されずタイムアウトした時点でtimeoutメソッド内の処理を終了します。throwした後は40行目でraiseしています(36~39行目はトラックトレースを整えているだけです)。ここのraiseはexceptionメソッド内でthrowされずにraiseしているのでTimeout::Errorが発生します。
パターン1
さてパターン1ではblの部分はどう処理されているでしょうか?
処理の図を作ってみました。さきほど説明した通りタイムアウトした場合スレッドyがexceptionをthrowします。パターン1の場合、スレッドxを中断してtimeoutメソッドまで戻り、そのタイミングでTimeout::Errorが発生します。timeoutメソッドにはtransaction部分が含まれておりthrowした際にtransactionを抜けたのでロールバックされなかったわけです。
パターン2
では同じようにパターン2もどう処理されているか考えてみましょう。
こちらも同じく図を作ってみました。タイムアウトした場合、スレッドyがexceptionをthrowします。パターン2の場合もthrowするとtimeoutメソッドまで戻ります。ですがパターン1とは違いtransaction部分はtimeoutメソッドに含まれていないのでtransactionは抜けません。transaction内でTimeout::Errorが発生するのでロールバックが発生していました。
まとめ
というわけでパターン1とパターン2ではtimeoutメソッドのthrow~catchでtransactionを抜けるかどうかが違いました。throw~catchは使ったことがなかったので全然気づきませんでした...
今回はライブラリのソースをしっかり見たことがなかったりthrow~catchの挙動をしっかり学んでなかったので良い勉強になりました。
読み解くのは中々大変でしたが勉強になると思うのでみなさんもたまにライブラリのソースを眺めてみるのはいかがでしょうか?