前回の記事にてタイムアウトとなった場合に2種類の例外がスローされる事を説明しました。クエリ実行中にタイムオーバーとなった場合は、QueryException。クエリ実行外でタイムオーバーとなり、その後クエリを実行してエラーとなる場合は、自作したTimeoutExceptionです。今回はこれらのエラーをどうキャッチしてハンドリングするのか解説します。

検証用のページ

説明の為にテスト用のコントローラを用意しました。

<?php

namespace App\Http\Controllers;

use App\Services\DbQueryTimeout;
use Illuminate\Support\Facades\DB;

class TimeoutTestController extends Controller
{
    public function index()
    {
        $timeout = (int) request('timeout');

        try {
            // 制限時間の指定があればセット
            if ($timeout) {
                DbQueryTimeout::set($timeout);
            }

            // 重いクエリ1
            DB::statement('select sleep(3) union select 1');

            // 重いクエリ2
            DB::statement('select sleep(5) union select 1');

        } catch (\Exception $e) {
            return 'error';
        }

        return 'success';
    }
}

ルーティングは以下の通りです。

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\TimeoutTestController;

Route::get('/timeout_test', [TimeoutTestController::class, 'index']);

indexアクションでは重いクエリが2つ実行されます。それらが正常に処理できた場合はsuccess、何かしらのエラーが発生した場合はerrorを返します。尚、GETパラメタでtimeoutの指定がある場合は、DbQueryTimeoutを使用して制限時間が設定されます。ビルドインサーバーを起動して挙動を確認してみましょう。

php artisan serv

まず、パラメタ無しで /timeout_test へアクセスしてみてください。約8秒後にsuccessと表示されるはずです。次に、timeoutパラメタを指定して制限時間を設定してみましょう。/timeout_test?timeout=5 として制限時間を5秒にしてアクセスしてみて下さい。すると、約5秒後に処理が中断されerrorと表示されるはずです。

Tips: 重いクエリをシミュレートする方法

重いクエリ = 時間が掛かるクエリ、という事でDB側でsleep()を実行してシミュレートできます。mysqlではsleep()は正常にスリープできた場合は0、途中で中断された場合は1が返却されます。しかし、実際の環境ではmax_execution_timeで指定した時間をオーバーして処理が中断された場合は1ではなくエラーが発生します。sleep()においてもタイムオーバーした場合にエラーを返させるには、unionで追加の処理を実行すればOKです。追加の処理はなんでも良いので今回は適当にselect 1としました。実際にmysql clientにて以下を実行して試してみると理解しやすいと思います。

# 制限時間を5秒に設定
mysql> set max_execution_time = 5000;
Query OK, 0 rows affected (0.01 sec)

# 制限時間内に終わるsleepなら0が返却される
mysql> select sleep(4);
+----------+
| sleep(4) |
+----------+
|        0 |
+----------+
1 row in set (4.05 sec)

# 制限時間内に終わらずsleepが中断されたら1が返却される
mysql> select sleep(5);
+----------+
| sleep(5) |
+----------+
|        1 |
+----------+
1 row in set (5.06 sec)

# unionで追加の処理を含めるとエラーが返却される
mysql> select sleep(6) union select 1;
ERROR 3024 (HY000): Query execution was interrupted, maximum statement execution time exceeded

エラーのハンドリング

冒頭で述べた通り制限時間をオーバーした場合は2種類のタイムアウトエラーが発生します。しかし、アプリにおいては当然それ以外のエラーも発生し得るので、タイムアウトエラーとそうでないエラーの場合で判別してエラーを出し分ける必要があります。それぞれどの様に判別すれば良いでしょうか。

TimeoutExceptionをキャッチ

先に簡単な方から、TimeoutExceptionを判別する場合です。こちらはシンプルにスローされたExceptionのインスタンスをチェックすれば判別できます。TimeoutTestController.php のindexアクションを以下の様に書き換えてみましょう。

...
    public function index()
    {
        $timeout = (int) request('timeout');

        try {
            // 制限時間の指定があればセット
            if ($timeout) {
                DbQueryTimeout::set($timeout);
            }

            // 重いクエリ1
            DB::statement('select sleep(3) union select 1');

            // クエリ以外の処理で3秒掛かった
            sleep(3);

            // 重いクエリ2
            DB::statement('select sleep(5) union select 1');

        } catch (\Exception $e) {
            // TimeoutExceptionの場合のエラー文言
            if ($e instanceof TimeoutException) {
                return 'timeout error';
            }

            return 'error';
        }

        return 'success';
    }

TimeoutExceptionは、クエリ実行外でタイムオーバーし、次のクエリが実行されるタイミングでスローされます。そちらを再現する為に、クエリ1とクエリ2の間にPHP側のスリープを3秒挟みました。これにより制限時間が5秒の場合はTimeoutExceptionがスローされるはずです。再度、ビルドインサーバにて/timeout_test?timeout=5へアクセスしてみて下さい。今度はtimeout errorと表示されたはずです。

クエリ実行中にタイムアウトした場合のエラー

クエリ実行中にタイムアウトした場合はQueryExceptionがスローされます。QueryExceptionはタイムアウト以外でもスローされる例外ですので、前項のTimeoutExceptionの様にインスタンスだけではタイムアウトエラーを判別できません。判別には一歩踏み込んで、$e->errorInfoのチェックが必要です。errorInfoQueryExceptionの親クラスであるPDOExceptionにセットされていた値で、PHPマニュアルによると、データベースハンドラにおける直近の操作に関連する拡張エラー情報が配列で格納されています。タイムアウトエラーとしてQueryExceptionがスローされた際の$e->errorInfoは以下の様になります。

[
  0 => "HY000"
  1 => 3024
  2 => "Query execution was interrupted, maximum statement execution time exceeded"
]

0はSQLSTATEエラーコード、1はドライバ固有のエラーコード、2はそのエラーメッセージです。判別には1のドライバ固有のエラーコードを使用します。こちらの記事によると、max_execution_timeの設定をオーバーした場合のエラーコードは MySQL 8.0.19 以前は30241028で、以降は3024に統一されたとの事です。私の環境はまだMySQL5.7なので、1028のエラーコードも補足する必要があります。これらを考慮して、TimeoutTestControllerの例外処理部分を次の様に修正しました。

...
        } catch (\Exception $e) {
            $timeoutError = false;

            switch (get_class($e)) {
                case TimeoutException::class:
                    $timeoutError = true;
                    break;

                case QueryException::class:
                    if (in_array($e->errorInfo[1], [3024, 1028])) {
                        $timeoutError = true;
                    }
                    break;
            }

            if ($timeoutError) {
                return 'timeout error';
            }

            return 'error';
        }

        return 'success';
    }
}

インスタンスのチェックはswitch文でget_class($e)でクラス名を取得して分岐する様に変更しました。QueryExceptionの場合はerrorInfoでエラーコードもチェックします。クエリ実行中にタイムオーバーとなるように/timeout_test?timeout=7へアクセスしてみて下さい。再度、timeout errorが表示されるはずです。

リファクタリング

これまで説明の為にコントローラ側にタイムアウトエラーのエラー処理のコードを記述してきましたが、DbQueryTimeoutと一緒に色々な箇所で使い回すのでまとめておいた方が良さそうです。という事で、DbQueryTimeout::isTimeoutError()にコードを移しました。

...
    // タイムアウトエラー発生?
    public static function isTimeoutError(\Exception $e): bool
    {
        $timeoutError = false;

        switch (get_class($e)) {
            case TimeoutException::class:
                $timeoutError = true;
                break;

            case \Illuminate\Database\QueryException::class:
                if (in_array($e->errorInfo[1], [3024, 1028])) {
                    $timeoutError = true;
                }
                break;
        }

        return $timeoutError;
    }
...

isTimeoutError()はシンプルにタイムアウトエラーか否かを判別するメソッドです。このメソッドを使う事でTimeoutTestController側は次の様にシンプルになりました。

<?php

namespace App\Http\Controllers;

use App\Services\DbQueryTimeout;
use Illuminate\Support\Facades\DB;

class TimeoutTestController extends Controller
{
    public function index()
    {
        $timeout = (int) request('timeout');

        try {
            // 制限時間の指定があればセット
            if ($timeout) {
                DbQueryTimeout::set($timeout);
            }

            // 重いクエリ1
            DB::statement('select sleep(3) union select 1');

            // クエリ以外の処理で3秒掛かった
            sleep(3);

            // 重いクエリ2
            DB::statement('select sleep(5) union select 1');

        } catch (\Exception $e) {
            if (DbQueryTimeout::isTimeoutError($e)) {
                return 'timeout error';
            }

            return 'error';
        }

        return 'success';
    }
}

By hikaru