同じ関数なのに別の注文のタグが付いた
一言でいうと
コンテキストの問題は、値があるかどうかだけでなく、いつコピーして、どこで復元したかの問題です。Task、遅延されたコルーチン、通常のスレッドプールを、同じ実行境界として扱わないようにすれば、リクエストが混ざる原因を、数行のコードの観測で絞り込めます。
なぜ必要なのか
注文2件を同時に処理するサービスがあります。注文Aを受け取ったらrequest=order-aを設定して、在庫確認を予約します。すぐに、呼び出し側の値が別のリクエストに変わります。ところが、在庫確認のログには、Aではなく、あとのリクエストのタグが出力されます。関数の引数も正常で、プロセスも1つなので、最初は、ログサーバーの並べ替えのエラーのように見えます。
このラボは、この状況を外部サーバーなしで再現します。Python 3.12とOpenTelemetry SDK 1.44.0がインストールされた環境で、実際のasyncioとThreadPoolExecutorを実行します。偽のログファイルに正解の文を書き込む方式ではありません。受講者の関数がコールバックを実行した瞬間のbaggageと、関数が戻ったあとの呼び出し側のbaggageを、それぞれ読み取って比べます。
前のモジュールで「スパンが受信されたか」を問うたとすれば、ここでは「その処理は誰のコンテキストで実行されたか」を問います。観測データが大量に届いても、別々のリクエストを誤ってつなぐと、障害の原因分析は間違います。収集量を増やす前に、つながりの意味を確認する必要がある理由です。
どう動くのか
値を作ることと、現在の値として適用すること
OpenTelemetryのbaggage.set_baggageは、値を入れたContextを返します。現在の実行にその値を適用するattachとは、同じ動作ではありません。attachが返したtokenでdetachすると、入る前のコンテキストに戻ります。Python Context APIとBaggage APIの契約を、区別して読んでください。Contextを作ったという事実だけで、コールバックがその値を読むと推測してはいけません。
ラボの最初のステップは、コールバックに値を見せつつ、元の呼び出し側を保持する小さなスコープです。呼び出し側の値はcaller、内側の値はorder-aです。コールバックが正常に返った場合とValueErrorを投げた場合を、別々に実行します。内側の値が合っていても、外側がorder-aのまま残るなら、半分しか合っていないコードです。返り値を捨てたり、例外を新しいものに変えたりしても、業務コードの契約を壊します。
復元を成功経路の一番最後にだけ置くと、途中の例外がその行を飛ばします。そのため、ラボでは、正常経路・例外経路の両方で、元の値が観測される必要があります。1つのリクエストだけを実行してプロセスを終了するテストでは、この欠陥を見つけにくいです。値が残ったあとで何が実行されるかまで見るテストが必要です。
3つの時点を書き出す
ラボの事前実験は、まずalphaを付けて、処理を作りました。次に、呼び出し側をbetaに変えてから、その処理を待ちました。実行順序は同じに見えても、どのAPIで処理を作ったかによって、ワーカーが読んだ値は違いました。
| alphaで行ったこと | betaに変えたあとで待ったときの実測 |
|---|---|
| create_task(coro) | Taskがalphaを読みます |
| to_thread(function)でコルーチンを作っただけ | スレッドがbetaを読みます |
| create_task(to_thread(function)) | スレッドがalphaを読みます |
この表は、このモジュールのPython 3.12での実験結果です。3行とも「あとで実行される処理」ですが、コンテキストを捕捉する時点は違います。2行目は、コルーチンオブジェクトを作っただけで、その本体はまだ実行していない点が重要です。3行目は、そのコルーチンを現在のコンテキストのTaskとして予約し、呼び出し側の変更から切り離しました。
Pythonのドキュメントでは、create_taskはデフォルトで現在のContextのコピーを使い、コルーチンを呼び出しただけでは実行が予約されないと説明されています。キャンセル時の後始末も、try/finallyと関連づけて説明しています。Python 3.12 asyncio。この契約を、上の実際の値と照らし合わせると、「asyncなら自動でできる」や「スレッドなら必ず消える」といったスローガンよりも、正確に問題を説明できます。
ラボのstart_taskとstart_threadは、予約した時点のリクエストを保持する関数です。ワーカーが正しい値を見るように、呼び出し側の現在の値を永久に元へ戻してしまうのは、正解ではありません。呼び出し側は、引き続きlater-callerである必要があります。両側を一緒に観察してはじめて、伝播とリークを区別できます。予約したTaskは返して、呼び出し側が待ったりキャンセルしたりできるようにします。この練習を、結果も参照も捨てるバックグラウンド処理のパターンに拡大しないでください。
通常のexecutorは別の実験
事前実験の通常のThreadPoolExecutor.submitは、alphaを自動では引き継ぎませんでした。提出する側でcontextvars.copy_contextでコピーし、そのコピーのrunの中で関数を実行すると、alphaが観測されました。続けて、同じワーカーに普通の関数を提出したときは、値がありませんでした。「一度引き継がれた」ことと、「次の処理に残らなかった」ことを、別々に確認したのです。
コピーをワーカー関数の中で行うと、すでに境界を越えたあとのコンテキストをコピーすることになります。受講者のコードにcopy_contextという名前があっても、実行順序が間違っていれば失敗します。採点は、単語があるかどうかを探さず、実際の返り値を比較します。提出ごとに別のコピーを作り、再利用されるワーカーの状態を保持するのが、このステップの課題です。新しいスレッドを毎回作ると、再利用時のリークの問題を隠してしまうことがあるため、同じワーカーで2つの注文を検査します。
キャンセルもリクエストの終わり
顧客が接続を切ったり、上位の処理がキャンセルされたりすると、コールバックは正常に返らないことがあります。ステップ5では、2つのリクエストをEventで同じ地点に集めてから続行して、互いに重なった区間の値を確認します。続いて、例外とキャンセルを発生させて、復元を確認します。最後に、実際に待機中のTaskにcancelを呼び出します。CancelledErrorを直接投げた場合だけが合格したからといって、外部からのキャンセル経路も検証したとは言えないためです。
キャンセルを捕まえて、普通の成功のように返すと、内側の値は復元されても、呼び出し側はキャンセルされた事実を知らないままになります。このラボの契約は、値を復元しつつ、例外とキャンセルを呼び出し側に伝えることです。後始末と、エラーの隠蔽は、同じことではありません。
現場での姿
次は、このラボで練習する調査の順序です。まず、リクエストを2つに分け、互いに異なる合成の識別子を使います。次に、呼び出しの直前・予約の直後・コールバックの内部・戻ったあとの値を記録します。正常経路が合っていれば、例外とキャンセルを入れます。スレッドプールがあれば、同じワーカーを再利用します。結果に、実行環境とAPI名も残して、次の人が別のランタイムの動作と誤解しないようにします。
このテストは、実際の個人情報や運用トークンを必要としません。order-aとorder-bだけで、伝播の時点とリークを区別できます。値が違うことだけが必要なときに、わざわざ実際の顧客の識別子を使うと、調査資料そのものが新たな管理対象になります。
次のラボですること
ステップ1–5で、小さなスコープの復元、Taskの予約、スレッドの予約、executorへの提出、重なったリクエストのキャンセル後の後始末を、順に直します。各実行のobservationsとchecksを見て、どの境界が間違っているかを説明してみてください。次の理論では、値が正確に伝わったという事実と、その値を信頼してよいという事実を分けます。伝達の成功は、認証の成功ではありません。