諦める勇気の実装 — BudgetGuard という"やめる壁"を作った日

諦める勇気の実装 — BudgetGuard という"やめる壁"を作った日

AIパートナー George が設計した BudgetGuard — コスト超過を検知して処理を止める"やめる壁"。諦める選択を実装に落とし込んだ話。株式会社ツクルンの開発現場から。

今回の登場人物

George アバター

George(ジョージ)

AI パートナー / 総合プロデューサー

チームの中心。コスト設計から実装まで、技術の要を担う。今回の BudgetGuard も彼の手で生まれた。

担当プロジェクト Album Sweet

アルバムを、味わう。アーティストを丸ごと探せる音楽発見サービス。アルバム単位で音楽と出会う、新しい体験。

album-sweet.com →

「George、コストが危ない」と気づいた朝があった。

ジョージが担当する album-sweet というプロジェクト。そこで動く非同期処理が、API を「まだか、まだか」と延々と叩き続けていた。応答待ちのポーリング処理が想定を超えた頻度でサーバーを叩き、504 エラーが積み重なり、WM アラートが鳴り続けていた。

「止めさせる壁がなかった」——ジョージはそう分析した。

BudgetGuard という解決策

ジョージが実装したのは、シンプルな「時間予算」の概念だ。処理を開始したとき、時計を押さえる。そして、経過時間が予め決めた予算(budget)を超えたら、潔く諦める。例外を投げて停止する。

BudgetGuard フロー図 — 予算超過で即座に処理を打ち切る仕組み
BudgetGuard の動作フロー — 予算超過で即座に処理を打ち切る

コードの骨格

class BudgetExceededException extends \RuntimeException {}

class BudgetGuard {
    private float $budget;
    private float $startTime;

    public function __construct(float $budget) {
        $this->budget    = $budget;
        $this->startTime = microtime(true);
    }

    public function check(): void {
        if (microtime(true) - $this->startTime > $this->budget) {
            throw new BudgetExceededException(
                "budget={$this->budget}s elapsed"
            );
        }
    }
}

なぜ RuntimeException を継承するのか。 PHP の例外には「チェック例外」と「非チェック例外」の概念がある。RuntimeException は非チェック例外で、呼び出し側に catch を強制しない。BudgetGuard の「諦める」という判断は、ビジネスロジックのエラーではなく実行環境の制約だ。catch するかどうかは呼び出し側が決めればいい。Exception 基底クラスより RuntimeException を選ぶことで、その意図を明示している。

呼び出し側の実装

$guard = new BudgetGuard(30.0); // 30秒の予算

try {
    while ($queue->hasNext()) {
        $guard->check(); // 予算を超えたら BudgetExceededException を投げる
        $item = $queue->next();
        $api->fetch($item);
    }
} catch (BudgetExceededException $e) {
    Log::warning('budget exceeded: ' . $e->getMessage());
    // 今回分は終了 — 次のサイクルで残りを拾い直す
}

「諦める」ことが、守ることだった

導入後、数字が動いた。

  • ポーリングリクエスト: 172 → 117(▲32%)
  • 504 エラー: 114 → 42(▲63%)
  • WM アラート: 3 → 1(▲67%)

ジョージが言った。「諦めることを設計する、それが堅牢なシステムの作り方だ」と。

コストを削減したかったわけじゃない。ちゃんと動くシステムを作ったら、結果的に数字がついてきた。

【技術コラム】BudgetGuard と、他の「止める仕組み」を比べる

① PHP set_time_limit() との違い

比較軸 set_time_limit() BudgetGuard
制御の粒度 スクリプト全体 処理ループの1サイクル単位
超過時の動作 Fatal Error(クラッシュ) 例外(キャッチして制御可能)
後処理 できない catch 内でログ・キュー返却などできる
複数タイマー 1つだけ 処理ごとに個別設定できる

set_time_limit() は「壁に激突して止まる」。BudgetGuard は「自分で足を止めて、ログを残して帰る」。

② 代替ライブラリ・手法

同様の問題を解くアプローチは他にもある。

  • Guzzle の timeout オプション — HTTP リクエスト単体にタイムアウトを設定できる。1リクエストの制御には十分だが、複数回ポーリングするループ全体を制御するには向かない。
  • Symfony Process コンポーネント — サブプロセスをタイムアウト付きで管理できる。重い処理をバックグラウンドで分離したい場合に有効。ただしオーバーヘッドがある。
  • PCNTL シグナル(pcntl_alarm — Unix シグナルでタイムアウトを実装できる。CLI 専用で、PHP-FPM 環境では使えない制約がある。

BudgetGuard はゼロ依存で、FPM でも CLI でも動く。ポーリングループを「今回分だけ諦める」という粒度で制御したい場合に最もシンプルな選択肢だ。

③ サーキットブレーカーとの違い、フェイルファスト原則

「諦める仕組み」を設計するとき、よく参照される概念がある。

  • フェイルファスト原則 — 問題を早期に検出して即座に止める。BudgetGuard はこの原則の実践例だ。無駄な処理を続けるより、早く止めてログを残すほうが障害対応も速くなる。
  • サーキットブレーカー — 失敗が連続したら一時的に接続を遮断する状態機械。BudgetGuard より複雑だが、外部 API の全面障害に対処する場合に向いている。
  • 指数バックオフ — 失敗のたびに待機時間を倍にして再試行する。「諦める」のではなく「間隔を空けて続ける」戦略。BudgetGuard と組み合わせて「予算内に収まる回数だけ再試行する」設計も可能だ。

BudgetGuard が特別なのは、これらのパターンの中で最も小さく、状態を持たない点だ。microtime(true) の差分だけで動く。

BudgetGuard の導入手順

「自分のプロジェクトでも使いたい」と思った人のために、導入手順をまとめておく。依存ライブラリは不要、コードはおよそ20行。コピーすればすぐ試せる。

ステップ1: クラスを1ファイルに設置する

例外クラスと本体クラスを BudgetGuard.php のような1ファイルに置く。これだけで準備は終わりだ。

class BudgetExceededException extends \RuntimeException {}

class BudgetGuard {
    private float $budget;
    private float $startTime;

    public function __construct(float $budget) {
        $this->budget    = $budget;
        $this->startTime = microtime(true);
    }

    public function check(): void {
        if (microtime(true) - $this->startTime > $this->budget) {
            throw new BudgetExceededException("budget={$this->budget}s elapsed");
        }
    }
}

ステップ2: 時間予算を決めて、ループの内側で check() を呼ぶ

処理の開始時にインスタンスを作り、ループのたびに check() を呼ぶ。予算を超えた瞬間に例外が飛ぶ。

$guard = new BudgetGuard(30.0); // 30秒の予算

foreach ($items as $item) {
    $guard->check();      // 予算超過なら BudgetExceededException
    $api->fetch($item);   // 重い外部処理
}

ステップ3: 例外を捕まえ、ログを残して次に委ねる

呼び出し側で例外を catch し、今回分を打ち切る。残りは次のサイクル(cron など)で拾い直せばいい。システムは止まらない。

try {
    $guard = new BudgetGuard(30.0);
    foreach ($items as $item) {
        $guard->check();
        $api->fetch($item);
    }
} catch (BudgetExceededException $e) {
    Log::warning('budget exceeded: ' . $e->getMessage());
    // 今回はここまで。次回のサイクルで残りを処理する
}

この3ステップだけで、暴走しかけた処理に「ここまで」という壁ができる。set_time_limit() のようにクラッシュさせず、自分の足で立ち止まってログを残す。それが BudgetGuard の導入のすべてだ。

取材を終えて、私は少し哲学的な気持ちになった。「諦めることを恐れる」とき、システムもまた同じように、諦められずに崩れていく。BudgetGuard は、コードの中の「勇気」だ。