お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、記事の分析を裏側で走らせている処理に不具合を見つけました。DevPickは、開発関連のニュースサイトをAIが横断してスコアリングし、その人の興味に合ったものだけを届けるWebアプリです。障害として表に出たわけではなく、コードを見直していて気づいたものなのですが、放っておくと「分析が二度と動かないサーバー」ができあがる状態でした。今回はその経緯を書きます。
裏の処理を「投げっぱなし」で起動していた
DevPickのフィードは、まだ分析が終わっていない記事を見つけると、その分析を裏側で走らせます。分析にはLLMを使うので数秒では終わりませんし、それを待ってから画面を返していたら、フィードを開くたびに長々と待たされることになります。そこで、分析の起動だけしてすぐ応答を返し、結果は後から反映される作りにしていました。
Pythonの非同期処理でこれをやるときは asyncio.create_task() を使います。渡した処理をその場で待たずに走らせてくれる関数で、書き方としては1行で済みます。私も素直に1行で書いていました。戻り値としてタスクが返ってくるのですが、こちらは待つ気がないので、受け取らずに捨てていたのです。
ここが落とし穴でした。Pythonの非同期処理を回している仕組みは、走らせているタスクを弱参照でしか持っていません。弱参照というのは「参照はしているが、それだけでは対象を生かしておく理由にならない」持ち方のことです。つまり、起動した側が戻り値を捨ててしまうと、そのタスクは誰からも本気で参照されていない状態になり、Pythonが使われていないオブジェクトを片付けるタイミングで一緒に回収されうるのです。実行の途中でも関係なく消えます。Pythonの公式ドキュメントにも、タスクが実行中に消えるのを避けるために戻り値の参照を保存しておくように、という注意書きがあります。
厄介なのは、これが「エラーとして起きない」ことです。例外が飛ぶわけでも、失敗のログが出るわけでもありません。起動した側から見れば、起動には成功しています。ただ結果がいつまでも反映されないだけです。
IT女子 アラ美消えるのは、その1回の処理だけではなかった
タスクが消えるだけなら、被害は「その回の分析が終わらない」で済みます。次にフィードを開いたときにまた起動されるので、いずれ追いつくはずです。ところがコードを読み直してみると、この処理はそういう作りになっていませんでした。
分析は同じワーカーの中で何本も同時に走らせたくないので、実行中かどうかを示すフラグを1つ持たせていました。起動する前にフラグを立て、分析が終わったら戻す、という素朴な作りです。そして「戻す」処理は、非同期処理の中の finally に置いていました。何が起きても最後に必ず通る場所なので、ここに置けば安全だと思っていたのです。
ところが、タスクごと消えてしまう場合はその finally にすら到達しません。フラグを立てた直後に処理そのものが回収されてしまうので、フラグは立てたまま誰も戻さないのです。しかもこのフラグはプロセスの中だけに持っている値なので、外から直す手段もありません。結果として、そのワーカーはフラグが立ちっぱなしになり、以降どれだけフィードを開いても「もう分析中だから起動しない」という分岐に落ち続けます。プロセスを再起動するまで、フィード経由の分析が二度と始まらない状態です。
未分析の記事はスコアが決まらないので、画面上は「分析中」の表示のまま残ります。ユーザーから見れば、ただ処理が遅いだけのように見えるはずです。実際にはもう誰も動いていないのに、待っているように見えてしまう。この「静かに止まる」性質が、いちばん怖いところだと感じました。



本来はlintで止まるはずが、設定から漏れていた
自分で見つけておいて言うのも変ですが、この不具合は目視で見つけるべきものではありません。書き方が決まっている以上、機械が判定できる話だからです。
実際、Pythonのlintツールとして使っているruffには、まさにこの書き方を指摘するルールがあります。RUF006 というルールで、名前のとおり「宙に浮いたタスク」を検出してくれます。CIでruffは既に走らせていたので、本来ならマージ前に止まっていたはずでした。
止まらなかった理由は単純で、設定でルールの集合を絞っていたからです。ruffは有効にするルール群を明示的に選ぶ形になっていて、こちらでは基本的なエラー・未使用・import順・警告の4種類だけを選んでいました。RUF006 はそのどれにも属していないので、ツールとしては検出できるのに、設定の側で最初から見ていなかったわけです。
では該当のルール群をまとめて有効にすればいいかというと、そう単純でもありませんでした。同じ群には、全角の括弧などを「紛らわしい文字」として指摘するルールが含まれています。日本語でコメントやドキュメントを書いているコードベースでこれを有効にすると、1500件を超える指摘が出ました。本当に見たい指摘がその中に埋もれてしまうので、群ごとではなく RUF006 だけを名指しで足す形にしています。
このあたりの「同じ種類の抜けを、人の注意力ではなく仕組みで止める」という考え方は、AIコーディング時代の開発フロー再設計で書いた品質ゲートの話と地続きだと感じています。一度踏んだ罠は、次に同じ書き方をした時点で機械に止めてもらうのがいちばん確実です。



起動口を1つにまとめ、後始末は終了通知側へ移した
直し方そのものは定石どおりです。起動したタスクをモジュールの中の集合に入れて強い参照として保持し、そのタスクが終わったタイミングで集合から取り除きます。こうしておけば、実行中のタスクは必ず誰かに参照されている状態になるので、途中で回収されることはありません。
ただ、該当箇所それぞれにこの数行を書き足す形にはしませんでした。今回の原因は「戻り値を保持し忘れたこと」であって、保持し忘れが起こりうる書き方が各所に散っている限り、次にもう1箇所増えたときにまた同じことが起きます。なので、バックグラウンド処理の起動口となる関数を1つ用意して、参照の保持と後片付けをその中で完結させ、呼び出し側は戻り値を気にしなくてよい形にしました。呼び出し側が忘れうる仕事は、呼び出し側から無くしてしまうのが確実です。
もう1つ変えたのが、実行中フラグを戻す場所です。先ほど書いたとおり、処理の中の finally は「処理が始まってから終わるまで」の保険にはなりますが、処理が始まる前に消えた場合には効きません。そこで、フラグを戻す処理をタスクの終了通知側に付け替えました。終了通知は、正常に終わっても、例外で終わっても、開始前にキャンセルされても呼ばれます。フラグの解放を「処理の内側の最後」ではなく「タスクが終わったという事実」に紐づけ直した、という整理です。
あわせて、例外で終わったタスクの扱いも決めました。参照を持つようにすると、誰も結果を受け取らないまま終わったタスクの例外は、後になってから報告される形になります。起動元はとっくに応答を返していて再送出しても届かないので、終了通知の中でログに残すようにしました。さらに、ルールを有効にしたことで、同じ書き方が別のところにもう1箇所あることが分かりました。パスワードを忘れたときの復旧メールを送る処理で、ログインできない人にとっては唯一の入り口です。今回の観点で一緒に直しています。個人開発のフィードでページ送りの仕組みを作り直した話のときも同じでしたが、1箇所直して終わりにすると、同じ性質の穴が隣に残りがちだと改めて感じました。



まとめ
応答を待たせないために裏で走らせる処理は、起動した時点で手を離してしまいがちです。ただ今回のように、手を離した結果そのものが消える仕組みになっていることがあり、しかも消えたことは例外にもログにも出てきません。表に出ないまま「もう動かないサーバー」ができあがるのは、失敗するより厄介だと感じました。
今の自分の結論は2つです。1つは、非同期で起動した処理は終わるまで参照を持ち続けること。もう1つは、状態を戻す後始末を処理の内側ではなく、タスクが終わったという事実の側に紐づけることです。そして書き方の間違いは、覚えておくのではなく、機械が止めてくれる状態にしておくのがいちばん確実だと考えています。












