例外の「誤嚥」が招く二重の破滅:Pythonの yield インジェクションと SQLAlchemy の OperationalError 衝突バグ
1. 概要
Pythonの @contextmanager デコレータは、yield を用いて「リソースのセットアップ」と「クリーンアップ(ティアダウン)」の処理を美しくカプセル化できる強力な仕組みである。 しかし、内部的にはこれは「ジェネレータ」として駆動しており、with ブロックの本体(Body)で発生した例外は、Pythonインタプリタによって yield 実行地点へ throw() メソッド経由で「逆注入(Inject)」されるという、トリッキーな仕様を持っている。 もし、ジェネレータ側で「一時的な接続エラーやロック失敗に対するリトライループ」を組んでおり、そこでキャッチする例外クラスが、呼び出し側から逆注入された例外クラスと衝突(重複)していると、例外を誤って飲み込み(誤嚥)、RuntimeError: generator didn't stop after throw() という不透明なクラッシュを引き起こして真のエラーを完全に隠蔽してしまう。 本稿では、Onyxのドキュメントインデックス処理で発生したジェネレータとDBエラーの衝突バグ(onyx-dot-app/onyx#10870)を事例に、Pythonの高度な制御フローとトランザクションエラーのハンドリング設計について分析する。
2. インシデントの分析:真のDBエラーを隠蔽する RuntimeError の罠
2.1. 現象:インデックス処理中に発生する原因不明の RuntimeError
Onyxが外部データソース(Google Driveなど)からドキュメントを同期し、データベースを更新するインデックス処理中、処理が突然ハングアップしたり、ログに以下の不透明なエラーだけを残してクラッシュする問題が発生した。
RuntimeError: generator didn't stop after throw()このエラーはPythonの内部仕様に関連するものであり、背後で「どのようなデータベース接続エラーやロックの競合が起きたのか」という真の原因(一次情報)がログから完全に抹消され、デバッグが極めて困難な状態に陥っていた。
2.2. 原因:@contextmanager と Python の throw() インジェクション仕様
問題の根本は、ドキュメント更新前にデータベースの行ロックを確保するコンテキストマネージャ prepare_to_modify_documents の「リトライループ」と、呼び出し側から送られてくる例外の「すれ違い」にあった。
このコンテキストマネージャは、他のプロセスとのロック競合を回避するため、以下のように「ロック取得の失敗(SQLAlchemyの OperationalError)」をキャッチしてリトライする設計になっていた。
# 概念的なバグコード
for i in range(_NUM_LOCK_ATTEMPTS):
try:
acquire_document_locks() # ロックの取得を試みる
yield transaction # 呼び出し側の `with` ブロックを実行
except OperationalError:
continue # ロック取得に失敗したらリトライこの状態で、呼び出し側の with ブロック本体(Body)の中で、実際のドキュメント更新処理を実行している最中に、以下のいずれかのデータベースエラーが発生したとする。
- デッドロック(
deadlock detected) - ロックタイムアウト(
lock_timeout) - コネクションの切断(Connection lost)
これらはすべて、SQLAlchemyにおいて OperationalError のサブクラスとして発生する。 呼び出し側の with ブロック内でこのエラーが起きると、Pythonはジェネレータの yield transaction のまさにその位置に、例外を throw() メソッドで逆注入する。
これにより、コンテキストマネージャ側の except OperationalError: ブロックが、「あ、自分がロック取得に失敗したんだな」と勘違い(誤嚥)して例外を飲み込み、そのまま continue で次のループへ進んでしまった。
そして、ループの次のイテレーションで 2回目の yield を実行しようとした。 しかし、Pythonの仕様上、with ブロックの例外を throw() されたジェネレータが、その例外を適切に外へ再スロー(Unwind)せずに再び yield しようとすると、「例外を投げたのにジェネレータが止まらなかった」として RuntimeError が強制発生する仕様になっている。
この結果、本来表示されるべき OperationalError: deadlock detected などの真のエラーが完全に隠蔽され、不透明な RuntimeError だけが表面化してシステムがスタックした。
3. 実体験:トランザクションリトライ中の例外「誤嚥」によるバグ
例外をキャッチしてリトライするコードにおいて、キャッチする範囲が広すぎたり、例外の「発生源(自クラスか、呼び出し先のクロージャか)」を区別しないと、予測不能な可用性障害を引き起こす。
- 実体験エピソード: データベースへの保存処理で、デッドロックやネットワークの一時的な瞬断に対処するため、「最大3回リトライする」デコレータ(
@retry_transaction)を自作して適用した。 このデコレータは、保存処理中に例外が発生したらロールバックして1秒待って再実行する仕様だった。 ある日、開発者が保存するデータの中に、バリデーションエラーを引き起こす不正な値(文字列長制限オーバーなど)を含めてリクエストを投げた。 本来であれば即座にValidationErrorで400エラーを返すべきだったが、デコレータが 「例外が発生したからリトライする」 と一律でキャッチし、「不正なデータで3回リトライを試み、そのたびに1秒ずつウェイトをかけて合計3秒待った挙動の末、最終的にエラーになる」 という、無駄な遅延が発生した。 さらに最悪だったのは、そのリトライ処理の中に、別接続で同じデータを確認する処理が入っていたため、リトライ中にロックが競合し、本来のバリデーションエラーではなく、データベース側の「ロックタイムアウト」が最終的なエラーとしてログに残ってしまったことだった。 「自分が投げたエラー」と「外から降ってきたエラー」を厳密に区別しなければ、デバッグは迷宮入りする。
4. 解決策と「例外インジェクション」における設計原則
Onyxのチームは、PR #10870 において、ジェネレータ内にシンプルな「状態フラグ」を導入することで、例外の発生源を厳密に区別するように修正した。
4.1. yielded フラグによる例外の出自(プロベナンス)判定
yield を実行する直前に yielded = True をセットする。 例外をキャッチした際、もし yielded が True であれば、その例外は「ロック取得(自分自身)」から出たものではなく、「with ブロックの本体(呼び出し側)」から逆注入されたものであると判断し、無条件で即座に再スロー(raise)してジェネレータを終わらせる。
# 修正後の堅牢なコンテキストマネージャ(document.py)
for i in range(_NUM_LOCK_ATTEMPTS):
yielded = False
try:
acquire_document_locks()
yielded = True
yield transaction # ここで呼び出し側に処理を渡す
except Exception as e:
# yieldした後に起きた例外(呼び出し側からの逆注入)は、そのまま上に通す
if yielded:
raise
# 自分がロック取得中に起こした OperationalError のみリトライを許可する
if isinstance(e, OperationalError) and i < _NUM_LOCK_ATTEMPTS - 1:
logger.warning(f"Lock acquisition failed, retrying...: {e}")
time.sleep(retry_delay)
continue
# それ以外の想定外の例外や、最後のリトライ失敗は即座にエラーを投げる
raise5. 結論
Pythonのジェネレータや @contextmanager は美しい抽象化であるが、その裏で動く throw() インジェクションという「OSの割り込みシグナル」に似た暗黙のデータフローを理解していなければ、深刻な例外の誤嚥と二重クラッシュ(RuntimeError)を招く。 コンテキストマネージャ内でリトライや例外処理を実装する際は、「yield の前(セットアップ時)の例外」と「yield の後(呼び出し側実行中)の例外」をフラグで厳密に区別する という、制御フローの分離を徹底しなければならない。
6. 参考文献 / 一次情報
- fix(indexing): fix generator didn't stop after throw(): onyx-dot-app/onyx#10870
- Python Generator
throw()specifications: Python PEP 342