トランザクション開始をインストルメンテーション前に実体化
遅延トランザクション実体化(lazy transaction materialization)において、BEGIN/SAVEPOINTクエリの実行時間が二重計上されていた問題が修正されました。トランザクション内の最初のクエリに対する sql.active_record 通知が、BEGINとクエリ本体の両方の実行時間を含んでいたため、実際には1往復のところが2往復に見える状態でした。
背景
遅延トランザクション実体化では、トランザクションブロック内で最初のクエリが実行されるまでBEGINの発行を遅延させます。この機能は 542f095 で導入され、接続検証をチェックアウト時から最初のクエリ実行時まで遅延させることで、接続関連の例外に対する再試行インフラを提供しています。
しかし、この実装ではBEGINの実体化が log() メソッドのインストルメンテーションラッパー内で行われていたため、最初のクエリの sql.active_record 通知にBEGINの実行時間が含まれてしまっていました。BEGINは独自の通知も発行するため、その実行コストが二重に報告される結果となっていました。
別のデータセンターのデータベースに対してクエリを実行した際、トランザクション内の最初のクエリが常に2往復を要しているように見えることから、この問題が発見されました。
技術的な変更
activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb の execute_intent メソッドが修正され、トランザクションの実体化がインストルメンテーション前に行われるようになりました。
変更前:
def execute_intent(intent)
log(intent) do |notification_payload|
intent.notification_payload = notification_payload
with_raw_connection(allow_retry: intent.allow_retry, materialize_transactions: intent.materialize_transactions) do |conn|
result = perform_query(conn, intent)
intent.raw_result = result
handle_warnings(result, intent.processed_sql)
end
end
end
変更後:
def execute_intent(intent)
should_dirty = false
if intent.materialize_transactions
# These can raise locally (e.g., ReadOnlyError). Validate before BEGIN.
intent.processed_sql
intent.type_casted_binds
materialize_transactions
end
log(intent) do |notification_payload|
intent.notification_payload = notification_payload
with_raw_connection(allow_retry: intent.allow_retry, materialize_transactions: false) do |conn|
should_dirty = intent.materialize_transactions
result = perform_query(conn, intent)
intent.raw_result = result
handle_warnings(result, intent.processed_sql)
end
end
ensure
dirty_current_transaction if should_dirty
end
log() の呼び出し前に materialize_transactions を実行することで、BEGINが独自の通知とともに完了してから、クエリのインストルメンテーションが開始されます。with_raw_connection の materialize_transactions パラメータは false に設定され、実体化は既に完了しているため重複実行を防ぎます。
intent.processed_sql と intent.type_casted_binds の事前呼び出しは、ReadOnlyError のようなローカルで発生する例外を、BEGINの発行前に検証するためです。これにより、トランザクションが不必要に開始されることを防ぎます。
テストによる検証
activerecord/test/cases/transaction_instrumentation_test.rb に追加されたテストは、イベントの重複が解消されたことを確認しています。
def test_sql_events_do_not_overlap
events = []
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
events << event
end
Topic.transaction { Topic.first }
assert_equal 3, events.size
begin_event, select_event, commit_event = events
assert begin_event.payload[:sql].start_with?("BEGIN")
assert select_event.payload[:sql].start_with?("SELECT")
assert commit_event.payload[:sql].start_with?("COMMIT")
assert_operator begin_event.time, :<=, begin_event.end
assert_operator begin_event.end, :<=, select_event.time
assert_operator select_event.time, :<=, select_event.end
assert_operator select_event.end, :<=, commit_event.time
assert_operator commit_event.time, :<=, commit_event.end
end
このテストは、BEGIN、SELECT、COMMITの各イベントが時系列順に並び、互いに重複していないことを検証します。begin_event.end <= select_event.time のアサーションにより、BEGINの完了後にSELECTのインストルメンテーションが開始されることが保証されます。
SAVEPOINTを使用するネストしたトランザクションについても、同様のテストが追加されています。
エラーハンドリングの改善
activerecord/test/cases/transactions_test.rb に追加されたテストは、実体化前のエラーがトランザクション状態を汚染しないことを確認しています。
def test_locally_rejected_query_does_not_materialize_or_dirty_transaction
ActiveRecord::Base.transaction do
connection = ActiveRecord::Base.lease_connection
ActiveRecord::Base.while_preventing_writes do
assert_raises(ActiveRecord::ReadOnlyError) { Topic.create!(title: "test") }
end
assert_not connection.current_transaction.materialized?
assert_not connection.current_transaction.dirty?
end
end
ReadOnlyError が発生した場合、トランザクションは実体化されず、dirtyフラグも設定されません。これは intent.processed_sql の呼び出しによる事前検証が機能していることを示しています。
接続が失われた場合の動作も検証されています:
def test_failed_materialization_does_not_dirty_transaction
ActiveRecord::Base.transaction do
connection = ActiveRecord::Base.lease_connection
connection.stub(:begin_db_transaction, -> (*) { raise ActiveRecord::ConnectionFailed, "connection lost" }) do
raised = assert_raises(ActiveRecord::ConnectionFailed) { Topic.first }
assert_equal "connection lost", raised.message
end
assert_not connection.current_transaction.dirty?
end
end
BEGINの実行中に ConnectionFailed が発生した場合、トランザクションはdirtyとしてマークされません。ensure 節での dirty_current_transaction 呼び出しは、should_dirty フラグが実体化成功後にのみ設定されるため、適切に制御されています。
設計判断
実体化をインストルメンテーション前に移動する設計により、以下の3つの目標が同時に達成されています。
- 正確なタイミング測定: 各SQLイベントが独立した時間範囲を持ち、実行コストの重複計上が解消される
-
早期エラー検出: ローカルで検出可能なエラー(
ReadOnlyErrorなど)をトランザクション開始前に検証する - 状態の一貫性: 実体化失敗時にトランザクションをdirtyとしてマークしない
should_dirty フラグを使用した制御フローは、実体化の成功と失敗を明確に区別します。ensure 節での条件付き dirty_current_transaction 呼び出しは、実体化が正常に完了した場合のみトランザクションをdirtyとしてマークする設計です。
まとめ
本PRは、遅延トランザクション実体化における計測の正確性を改善しました。トランザクションの実体化をインストルメンテーション前に移動することで、BEGIN/SAVEPOINTとクエリ本体の実行時間が明確に分離され、パフォーマンス分析の精度が向上します。また、エラー検証の前倒しにより、不必要なトランザクション開始を防ぐ副次的な効果も得られています。