はじめに
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への変換を提案していた。これに対しレビュアーは次のように指摘している。
Deadlockedhas a meaning inside transaction, re-using this class could have bad repercussions.
I thinkStatementTimeoutcould 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