1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SQLite3::BusyExceptionはなぜStatementTimeoutになったのか

1
Posted at

はじめに

Rails 8ではSolid Queue/Solid Cache/Solid CableといったSQLiteベースのバックエンドが標準になり、SQLiteを本番運用の前提に据える方向性が明確になった。PR #52577はその流れの中で出た小さな修正で、SQLite3::BusyException(DBファイルがロックされている際に発生する例外)をActiveRecord::StatementInvalidではなくActiveRecord::StatementTimeoutに変換するというものだ。

diffは数行だが、レビューでは最初に提案されていた別のクラスが却下されている。その理由を追う。

何が変わったか

修正はSQLite3Adapter#translate_exceptionのelsifチェーンに1つ分岐を足すだけである。実際の差分はこうなっている。

           elsif exception.message.match?(/called on a closed database/i)
             ConnectionNotEstablished.new(exception, connection_pool: @pool)
+          elsif exception.is_a?(::SQLite3::BusyException)
+            StatementTimeout.new(message, sql: sql, binds: binds, connection_pool: @pool)
           else
             super
           end

このPR以前はSQLite3::BusyExceptionがどのelsifにもマッチせず、elseからsuper(AbstractAdapter#translate_exception)に落ちていた。superの中身はこうなっている。

# activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
def translate_exception(exception, message:, sql:, binds:)
  case exception
  when RuntimeError, ActiveRecord::ActiveRecordError
    exception
  else
    ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds, connection_pool: @pool)
  end
end

つまりこのPR以前は、ただのActiveRecord::StatementInvalidにラップされるだけだった。「ロック競合で失敗した」という情報は、他のどんなSQLエラーとも区別がつかない形に潰れていたことになる。

なぜDeadlockedではなくStatementTimeoutなのか

PRの提出者は当初ActiveRecord::Deadlockedへの変換を提案していた。これに対しレビュアーは次のように指摘している。

Deadlocked has a meaning inside transaction, re-using this class could have bad repercussions.
I think StatementTimeout could work.

errors.rbのDeadlockedのドキュメントコメントを見ると、この懸念の意味が分かる。

# Deadlocked will be raised when a transaction is rolled
# back by the database when a deadlock is encountered.
#
# This is a subclass of TransactionRollbackError, please make sure to check
# its documentation to be aware of its caveats.
class Deadlocked < TransactionRollbackError
end

Deadlockedは単に「デッドロックが起きた」ではなく、「トランザクション全体がDBによってロールバック済み」という強い含意を持つ。SQLiteのBUSY状態はロックが空くのを待って失敗しているだけで、トランザクション自体が破棄されたわけではない。ここでDeadlockedを流用すると、それをrescueするアプリのコードに「トランザクションは終わっているので最初からやり直す」という誤った前提を与えてしまう。レビュアーの懸念はこの一点であり、代替のStatementTimeoutについて深い検討がされた形跡はレビュー上には残っていない。

ちなみにStatementTimeoutと並ぶLockWaitTimeoutという名前には紆余曲折がある。2017年8月、候補に上がったLockWaitTimeoutは「TransactionTimeoutの方が汎用的でいい」という理由で一度見送られた(PR #30360)。ところが同年11月にStatementTimeoutが新設されると、今度はTransactionTimeoutの汎用性が仇になり、結局LockWaitTimeoutに改名し直されて現在に至る(PR #31223)。名前の適切さは、その時点で何が隣にあるかに左右される。

Deadlocked自体は2016年から存在しており、MySQLのエラー1205(ロック待ちタイムアウト)はこの2017年の変更まで専用クラスを持たず、elseからsuper(汎用のStatementInvalid)に落ちていた。今回のSQLiteのBusyExceptionと同じ構図が、7年前のMySQLでも起きていたことになる。

またこのPRのテストは、stepメソッドをstubしてSQLite3::BusyExceptionを直接投げさせているだけで、実際にSQLiteの排他ロックを競合させて再現したものではない。

statement.stub(:step, -> { raise ::SQLite3::BusyException.new("busy") }) do
  assert_raises(ActiveRecord::StatementTimeout) { @conn.exec_query "..." }
end

「この例外が来たらこう変換する」という配線は検証されているが、「本当にこの状況でこの例外が飛ぶか」は実機で確認されていない。

まとめ

  • このPRはSQLite3::BusyExceptionを汎用的なStatementInvalidからStatementTimeoutに変換し、他のSQLエラーと区別できるようにした
  • 当初案のDeadlockedが却下されたのは、そのクラスが「トランザクション全体がロールバック済み」という強い意味を持つため。SQLiteのBUSY状態はそれとは異なる状態であり、意味を取り違えたままrescueされるリスクがあった
  • errors.rbのドキュメントコメントを読むと、例外クラスの選択がレビューの一言だけでなく、クラス自体の設計意図に裏付けられていることが分かる
  • テストはstubによる配線確認にとどまり、実際のロック競合による再現はされていない

参考:検証に使ったコマンド

cd ~/Documents/oss/rails

# PRの差分と会話を確認
gh pr diff 52577 --repo rails/rails
gh pr view 52577 --repo rails/rails --json title,body,url
gh api repos/rails/rails/pulls/52577/comments --jq '.[] | "\(.user.login): \(.body)"'

# 例外クラス階層とDeadlockedのドキュメントコメントを確認
grep -n -B5 "class Deadlocked" activerecord/lib/active_record/errors.rb

# super先(AbstractAdapterのフォールバック)を確認
grep -n -A10 "def translate_exception" activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

# LockWaitTimeoutが2017年に一度見送られ、後に改名し直された経緯を確認
gh pr view 30360 --repo rails/rails --json title,body
gh api repos/rails/rails/issues/30360/comments --jq '.[] | "\(.user.login): \(.body)"'
gh pr view 31223 --repo rails/rails --json title,body

# Deadlockedが先に存在し、LockWaitTimeoutが後から切り出された経緯を確認
git blame -L '/class Deadlocked/,+1' --date=short activerecord/lib/active_record/errors.rb
git show c5edd97721 -- activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb

# このPRのテストがstub頼みであることを確認
grep -n -B10 "BusyException" activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?