RACCOON TECH BLOG

株式会社ラクーンホールディングス 技術戦略部より、
tipsやノウハウなど技術的な話題を
発信いたします。

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 email
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の挙動をしっかり学んでなかったので良い勉強になりました。
読み解くのは中々大変でしたが勉強になると思うのでみなさんもたまにライブラリのソースを眺めてみるのはいかがでしょうか?

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc