CIを無言でハングさせた「未コミットロック」の闇:Onyxで起きた 38分間のテストデッドロック障害
1. 概要
CI/CD(継続的インテグレーション)パイプラインにおいて、テストランナーがエラーも警告も吐かずに「無言で長時間ハングアップする」問題は、開発チームの生産性を致命的に低下させる。 このサイレント・ハングの代表的な原因が、テストフィクスチャによる**データベース接続の「未コミットロック(Uncommitted Lock)のリーク」**と、テスト解体時(Teardown)の別セッションによる書き込み競合(デッドロック)である。 本稿では、Onyxのテストインフラで発生した38分間のテストハング障害(onyx-dot-app/onyx#11482)を事例に、ORM(SQLAlchemy等)とテスト設計におけるトランザクション分離、およびデータベースロックのタイムアウト制御について分析する。
2. インシデントの分析:CIを沈黙させた「テスト後片付け」の衝突
2.1. 現象:38分間のタイムアウトまで無言でハングするCI
OnyxのCI/CD実行時、特定の統合テストを実行した直後、テストランナーが完全にレスポンスを失い、CIランナーの上限(38分間)に達するまで一切のログを出力せずにフリーズし続ける現象が発生した。
このハングアップは一時的なネットワークのFlaky(不安定さ)ではなく、特定のテストコードがデータベースへの書き込みロックを掴んだまま膠着状態に陥る、100%再現する「テスト解体時(Teardown)のデッドロック」であった。
2.2. 原因:未コミットのロックリークと別セッションによる削除の衝突
このデッドロックが発生したメカニズムは、SQLAlchemyなどのORMにおける「トランザクション制御」と「フィクスチャの解体順序」の隙間から生じていた。
- 未コミット状態でのロック保持: テスト内の特定のヘルパー関数(例:
ensure_sandbox_pat)が、特定のデータベースセッション(db_session)に対してデータを挿入・更新し、flush()を実行した。この操作によりデータベース上に排他ロック(Row-level Lock)が発生したが、トランザクションをcommit(確定)もrollback(取り消し)も行わないまま、接続が開いた状態で処理を終了した。 - 別セッションからのデータ削除要求(Teardownの衝突): テスト自体が完了すると、Pytest等のフレームワークによってテストデータをクリーンアップするためのTeardown処理が走る。 ここで、別のフィクスチャである
test_userが、別のデータベース接続(別セッション)を用いて、テスト用ユーザーレコードを物理的に削除(DELETE)しようとした。 - 死の膠着(デッドロック): この削除(DELETE)クエリは、最初のセッションが未コミットのまま掴み続けている排他ロックの解放を待つ。 しかし、最初のセッション(ロックを掴んでいる接続)はLIFO(後入先出法)の順序により、削除処理が完了した「後」にしかクローズされない。 結果として、別セッション同士が「相手がロックを解放する」のを待ち合うデッドロック状態となり、どちらの処理も進まずにテストプロセス全体無言で永久フリーズに陥った。
2.3. 「実行計画(EXPLAIN)を見ない」エンジニアの量産とORMの罪
このテストデッドロックのような深刻なデータベース障害が開発現場で後を絶たない根本的な原因は、ORMのブラックボックス化によって**「実行計画(EXPLAIN)すら見ないエンジニア」が多数派を占めるようになってしまった**という厳しい現実にある。
生SQLを記述するエンジニアであれば、クエリの遅さに対して EXPLAIN ANALYZE を実行し、Seq Scan(フルスキャン)や非効率な Nested Loop を検知してインデックスやクエリ構造を修正する「オプティマイザとの対話」が日常的に発生する。 しかし、ORMがSQLを隠蔽した結果、開発者は裏側でどのようなクエリが自動生成されているかに関心を持たなくなる。 これにより、結合(JOIN)の優先順位やサブクエリでの絞り込み条件が考慮されず、データベース内部で「数百万件のデータからなる壮大な一時テーブル(デカルト積)をメモリ上に生成してから、最後の最後でフィルタリングする」といった、データベースを窒息させる最悪の実行計画が平気で放置される。
しかし、エージェントコーディング時代において、この「人間が面倒がってサボる実行計画の検証」は、インフラと結合したAIによって完全に自動化される。 AIはすべてのSQLに対してCI上で自動的に EXPLAIN を実行し、フルスキャンや不要なディスクソートを検知した瞬間に、クエリの最適化やインデックスの自動追加を自律的に完了させる。 「実行計画を見ない人間」の直感に頼る設計から、「AIが100%のクエリの実行計画を監査し、明示的なRaw SQLで極限まで最適化する」パラダイムへの移行こそが、真の可用性とシステム性能を担保する道である。
2.4. 単体テストをすり抜ける「N+1」とキャッシュの偽りの麻酔
データベースのパフォーマンス崩壊の定番である「N+1問題」が、どれだけ基本知識として共有されていても本番システムで蔓延し続けるのは、**「通常の単体テスト(Unit Test)では100%見落とされる」**というステルス性にある。
単体テストの環境では、テスト実行速度を上げるために少量のデータ(N=1〜3程度)しか投入されず、SQLiteのインメモリDBやモックが多用される。そのため、N+1クエリが裏で何回走ろうとも、ミリ秒単位で処理が完了してテストは正常終了(グリーン)してしまう。
さらにタチが悪いのは、ORMが持つ「Identity Map」や「セッション内キャッシュ」という**偽りの麻酔(隠蔽膜)**の存在である。 テストやローカル開発環境では、直前にデータをインサートして同じセッションのまま実行するため、メモリ上のキャッシュが100%効いた状態で動く。結果として、コンソールにSQLログすら流れず、極めて高速に動作する。 しかし、本番環境で並行アクセスが急増し、キャッシュ上限によるパージや別セッションによるコールドスタートが発生した瞬間、この麻酔は切れ、隠れていた数万件のN+1クエリが一気にデータベースへ雪崩(Thundering Herd)のように押し寄せ、データベースのCPUを焼き尽くす。
この「見えない地雷」をCIで確実に防ぐためには、単にデータ取得を確認するだけのテストではなく、**「発行されるSQLクエリの総数をインターセプトしてアサートする(例: assert_num_queries(limit=2))クエリカウンタテスト」**をAIに自動設計させ、データ件数が変化してもクエリ数が $O(N)$ に増殖しないことをコードレベルで保証する「Fail-Fast」なテストの義務付けが必要不可欠である。
3. 実体験:マルチスレッドバッチにおける「暗黙のトランザクション」による接続プール枯渇とデッドロック
ORM(Object-Relational Mapping)ライブラリは、「データベースへのアクセスをオブジェクトとして隠蔽する」ことで開発を高速化するが、暗黙のうちにトランザクションを開始し、接続を掴み続けるという罠を内包している。
- 実体験エピソード: バックグラウンドで一定時間ごとにAPIからデータを取得し、データベースに保存するPythonの Celery バッチプログラムを開発した。 データを取得し、DBに保存する処理は単純だったが、しばらく動かしていると、特定の時間帯にバッチプロセスがすべてフリーズし、データベースへの接続が「Too many connections」で飽和してシステム全体がハングアップする障害が発生した。 コード上、接続はすべてコンテキストマネージャで適切に
close()しているはずだった。 調査した結果、ORM(SQLAlchemy)の仕様で、データベースから「データを参照(SELECT)」しただけで、背後で自動的にトランザクションが開始されていたことが判明した。 データ更新処理がないため、開発者は「ただの読み込みだからコミットもロールバックも不要」と思い、そのまま関数を抜けていた。 しかし、トランザクションが開いたままになっているため、Postgres側はその接続を「稼働中(Idle in transaction)」とみなし、接続プールに返却された後も物理的な接続を解放しなかった。 この状態で別スレッドから書き込み(UPDATE)が発生した際、読み込み時の暗黙のロックと書き込みロックが衝突し、接続上限に達してフリーズしていたのである。 「参照であっても、処理が終わったら必ず明示的にコミットまたはロールバックを呼んでトランザクションを明示的に閉じる」という原則を怠ったことが原因だった。
4. 解決策と「テスト用DBトランザクション」の設計原則
Onyxのチームは、PR #11482 において、テストフィクスチャのクリーンアップ処理に以下の強力なセーフティネット(防衛線)を導入した。
4.1. 解体開始時の明示的な rollback() の実行
test_user フィクスチャの Teardown 処理の開始直後、まず共有の db_session に対して明示的に db_session.rollback() を呼び出し、これまでのテスト実行中に発生した「未コミットのロック」を一度すべて強制解放(リセット)した上で、安全にレコードの削除を実行する。
4.2. データベースレベルでの lock_timeout の強制適用
万が一、別の箇所でコミット漏れが発生し、ロックの競合が起きたとしても、テストが永久にハングするのを防ぐため、削除用セッションのパラメータにSQLレベルで短時間の lock_timeout = 10s(10秒) を強制設定する。 これにより、10秒以上待たされたクエリは即座にエラーとなって例外を吐くため、テストランナーは沈黙せずに「Fail-Fast(最速での失敗)」を検知して終了でき、CIのパイプラインが何十分も拘束されるのを防止できる。
# テスト後片付け時の堅牢な削除の実装イメージ
def _best_effort_delete_user(db_session, user_id):
try:
# ロック待ちで永久フリーズするのを防ぐ防衛線 (Fail-Fast)
db_session.execute("SET statement_timeout = 10000") # 10s
db_session.execute("SET lock_timeout = 10000") # 10s
db_session.rollback() # 既存の未コミットロックを強制解放
db_session.delete(user)
db_session.commit()
except Exception as e:
logger.error(f"Failed to delete test user: {e}")
db_session.rollback()5. 結論
テスト環境やデータベース連携アプリにおいて、暗黙的に開始されたトランザクションの未コミット状態は、可用性を沈黙させる「最悪のバグ」を誘発する。 システムを堅牢にするためには、「データベース操作は、参照であっても必ず最後に明示的にクローズ(commit/rollback)する」r、「テストのTeardownには、永久フリーズを防ぐための lock_timeout / statement_timeout を必ず設定する」 という、データベースエンジニアリングの防護原則を徹底しなければならない。
6. 参考文献 / 一次情報
- fix(craft): green the Craft CI lanes (opencode-serve readiness, model-catalog cache, test deadlock): onyx-dot-app/onyx#11482
- SQLAlchemy Session Transaction management: SQLAlchemy Documentation