前書き
終電はとうに過ぎていた。少しの性能向上、そのための小さなコード修正、それだけのはずだった。思いつきで取り組み始めてしまったことを、彼は後悔していた。
動くはずのコードが動かない、それだけならまだ良い。エラーもログも何も出ないままWebサーバがハングする。修正を試みながら何度かブラウザのリロードボタンを押していたら、OSが無応答になりサーバから締め出されてしまった。ホスティング事業者の時間外窓口に電話しサーバの再起動を依頼。一旦は事なきを得たが、再発の度に再起動依頼の電話を入れるわけにもいかない。次に何をすれば良いか解らず、彼は途方に暮れていた。
彼が扱っていたのはPHPだ。エラーはその場で報告されるはず。コードの実行に時間がかかりすぎている場合も、設定に応じてタイムアウトするはずだ。だがそうならない。実行を開始した瞬間、CPU使用率が100%に張り付き、何も報告されない。
「何か、自分の書き方が悪いに違いない。」
彼はそう信じて疑わなかった。プログラミング歴のそう長くない彼にとって、問題が起きた時に最も疑わしく感じるのは自分の書いたコードだ。借り物のサーバを壊してしまうわけにはいかないので、彼はその場でローカルマシンにPHPとApacheをインストールし試行錯誤を始めた。
自分が書いたコードの何が悪いかは解らない。ただ、サーバが止まってしまうくらいなので「何か」が悪いのだろう。命令の順序を変える、関係ありそうな別の命令を追加する、幾つかのことを試し、少なくともハングせずに済むコードを書くことには成功した。当初の目的であった性能向上も達成できていた。なぜサーバから締め出されるような事態になったのかは、最後まで解らないままだった。
「本当に直せたのだろうか?まだ悪い書き方が残っていないだろうか?」
不安を払拭しきれないまま、朝方、彼は眠りについた。
ブラウン管の熱にうなされながら眠る彼を見下ろし、男は微笑みを浮かべる。
「安心して。あなたは悪くない。その『バグ』は、あなたが20年後に直すんだよ。」
バグの正体: 接続の状態遷移
お気付きの通り「彼」とはかつての私である。そして「男」は現在、pdo_pgsqlの機能追加に取り組んでいる:
その過程で既存機能のバグを見つけた。Webサーバをハングさせる最小のPoCは次の通り:
<?php
$pdo = new Pdo\Pgsql('pgsql:');
$pdo->setAttribute(Pdo::ATTR_ERRMODE, Pdo::ERRMODE_SILENT);
$pdo->exec('copy foo to stdout');
$pdo->copyToArray('foo'); // ここでハング
かつて「彼」は、大量データをバルク処理することでパフォーマンスアップを図ろうとしていた。COPY文を受け取ったPostgreSQL接続は COPY_IN, COPY_OUT などへ状態遷移するが、pdo_pgsql の幾つかの処理がそれを想定していないため infinite busy loop に入る。
PostgreSQLのクライアントライブラリである libpq は「次の文を実行する前に、現在の文の結果をループで全て取得し終えろ」と要求する。それに従い、pdo_pgsqlは結果が空になるまでループする。ところが、コピー中の接続は、何度結果を取得しても「コピー中である」を返し続ける。結果、無限ループ。Cの世界の出来事なので max_execution_time の割り込みも届かない。これが20年前のあの日、「彼」を苦しめたバグの正体1である。
取り組み中の機能追加と関係する箇所は既に修正した:
COPY 以外にも様々な箇所を修正している。何れも接続管理の不備による状態のリークだ。修正済みのバグの多くは PHP 8.5 の機能追加に由来していた。修正の済んでいない箇所も、近日中にプルリクエストを作成する予定だ。
初出の時点でバグを抱えていた COPY はともかくとして、なぜPHP 8.5で問題が表面化し始めたのか?それを深掘りすると、PDOやデータベースドライバに対する「次の一手」が見えてくる。
複雑化・大規模化するアプリケーションに対する「次の一手」
PDO や pgsql、mysqli 拡張が作られた当時のPHPは LAMPスタック、LAPPスタック などと呼ばれるシンプルな構成と共に使われ、取り扱うシステムの規模もデータ量も、それほど大きくはなかった。
この前提は、pdo_pgsqlの設計にも「割り切り」として反映されている。PHPコード上では $statement->fetch() で1行ずつデータを取得しているように見えても、実は裏側では予め全行を取得し、C言語の世界ではその分のメモリが確保されている。これは、当時としては妥当な設計判断だろう。
だが、大規模アプリケーションでもPHPが使われるようになった現代では、この設計の限界が露呈することも少なくない。従来はアプリケーションレイヤーで行われていた lazy fetch や chunk fetch を、PHPネイティブで行う必要が出てきた。これが転換点である。
従来の設計
- SQL文を実行すると結果が得られる
新たに求められる設計
- SQL文を実行するが結果はすぐ取得しない
- 後で必要に応じて必要な分だけ取得する
- 未取得の結果が無くなった時点でSQL全体の処理が終了
要するに、新たな設計では、接続の「状態」を管理する必要がある。概念的には次のような形だろう:
- その接続は、何も文を実行していない
- その接続は、文の実行を終えたが結果の取得は終えてない
- その接続は、文の実行も全ての結果の取得も終えた
従来の設計、つまり「SQL文を実行すると(PHPスクリプトの世界で見えるかはともかくC言語の世界では)一緒に結果の取得まで終えてしまう」という割り切った設計の場合、これらの状態管理は不要である。COPYという例外を除けば。
- その接続は、コピー処理を行っている最中である
「新たな設計」の導入はPHP 8.5からで、先に挙げた私の機能追加プルリクエストもその延長線上にある。既に私が修正したバグの多くがPHP 8.5から発生していたのはこのためで、COPYだけは、PHP 8.5を待たずに同じ形の状態を抱えていた。
こうして、PHP 8.5で露出した新たなバグと、20年前に「彼」が遭遇したバグを、同時に直すことになった。
未来の自分に誇れる日々を
「安心して。あなたは悪くない。その『バグ』は、あなたが20年後に直すんだよ。」
修正しようとしているバグが20年前の自分を苦しめたものだと気付き、当時を思い出した瞬間、私は白昼夢のような感覚で「彼」に話しかけていた。決して大げさな表現ではない。本当に、安心して欲しいと思ったのだ。
関数を呼び出しただけでハングという異常事態。それを見てもなお、自分の書き方が悪いに違いない、そう信じて疑わず怯えながら修正をしていた当時の自分。
それが今やどうだ。一瞥しただけでバグと判断し、修正PRが受理された次の瞬間には水平展開先まで見つけている。先のPoCも近い将来ハングしなくなるはずだ。そして当時の事情や設計判断を考察するブログまで書き、その先の進化を担うのは自分の使命だとまで考え始めている。
思えば遠くに来た。かつてチャレンジした小さな性能向上、動かないからと言って安易に戻したりせず諦めない姿勢、正体が解らないことに対する不安、どれも本気の取り組みの結果だ。その積み重ねで、ここに来られたのかもしれない。
20年後の自分は、今の私に微笑んでくれるだろうか?
Footnotes
-
当時は pdo_pgsql ではなく pgsql 拡張を使っていたためコード例は正確には異なる。ただし、pgsql 拡張にも全く同じ形のバグが存在する。 ↩