Schedulable Apexの自己再スケジュール設計で後悔したこと

後から「やらなければよかった」と思った設計

当時の私は、外部連携を担うBatch Apexを定時実行するためにSchedulable Apexを使っていました。特に意識していたのは「堅牢性」でした。外部APIの呼び出しは常に失敗する可能性があるので、エラーが発生した際には自動的にリトライする仕組みが必要だと考えていたのです。

そこで、「Batch Apexのスケジュール自体が失敗した場合や、Batch Apex内で致命的なエラーが発生した場合(例えば、予期せぬAPI認証エラーでBatch全体が失敗する場合など)には、Schedulable Apex自身を再スケジュールして、後でリトライしよう」と判断しました。

なぜそう判断したか:その時の私の思考

「非同期処理は失敗するもの」という前提が頭の中に強くありました。システム連携において、外部サービスが一時的にダウンしたり、ネットワークが不安定になったりすることは日常茶飯事です。手動での再実行はオペレーションコストが高いと考え、Apex側で可能な限り自動化しようと思いました。

Schedulable Apexの execute メソッド内で try-catch ブロックを使い、例外が発生したら、数分後に同じSchedulable Apexクラスを System.schedule で再度登録すれば、自動的にリトライできる、これは完璧なエラーハンドリングだと信じていました。

global class MyExternalApiScheduler implements Schedulable {
    global void execute(SchedulableContext sc) {
        try {
            // 外部APIを呼び出すBatch Apexをスケジュール
            Database.executeBatch(new MyExternalApiBatch(), 200);
        } catch (Exception e) {
            System.debug('ERROR: Failed to schedule MyExternalApiBatch. Error: ' + e.getMessage());
            // ここで、私にとっての「最高のアイデア」が生まれた
            // 30分後に自身を再スケジュールしてリトライする
            // この時、CronTrigger名にTimestampを付与してユニークにしていた
            System.schedule('Retry_MyExternalApi_' + System.currentTimeMillis(), '0 30 * * * ?', new MyExternalApiScheduler());
            // 今ならこうは書かない。
            // Schedulable Apexは、あくまで「スケジュールトリガー」に徹するべきだった。
            // 非同期ジョブの再試行ロジックは、Queueable Apexのチェインや、
            // 失敗レコードを特定して別の監視プロセスで再実行する形にするべきだった。
            // あるいは、Batch Apexのexecuteメソッド内で個別のレコードに対してリトライロジックを実装する。
        }
    }
}

実際に発生した問題点

この「賢い」と思った設計は、本番環境で実際に大きな問題を引き起こしました。

  1. 非同期ジョブキューの枯渇(Apex Flex Queue / Concurrent Limits):

    ある日、連携先の外部APIが長時間にわたってメンテナンスで停止しました。すると、私のSchedulable Apexは30分おきにエラーを捕捉し、律儀に自分自身を再スケジュールし続けました。結果、Salesforce組織のApex Flex Queue (上限50ジョブ) や、長時間実行されるApexの同時実行制限 (上限10ジョブ) をあっという間に使い果たしてしまいました。他の重要な非同期処理 (Future, Queueable, Batch) が一切実行されなくなり、組織全体の業務が停止寸前になりました。

  2. CPUタイムのリミット超過のリスク:

    Schedulable Apexの execute メソッド自体が長時間実行されることは稀ですが、無限に自分をスケジュールし続けることで、不要な処理が積み重なり、結果的に組織全体のCPUタイムを圧迫するリスクがありました。特に、私の場合はBatch Apexを呼んでいましたが、もしSchedulable Apex内部で複雑な処理を行っていたら、さらに状況は悪化したでしょう。非同期ApexのCPUタイムリミットは 60,000ms (60秒) です。

  3. 監査性の欠如とデバッグの困難さ:

    System.schedule でスケジュールされたジョブは、CronTrigger オブジェクトとして登録されます。毎日決まった時間に実行されるべきジョブが、ログを見ると何時間も連続して実行され続けている。しかもその一つ一つが異なる CronTrigger IDを持っているため、どれが元々のスケジュールで、どれがリトライによって生成されたものなのか、一見して判別できませんでした。監査ログを辿るのに大変な労力と時間がかかり、当時の私は夜中に冷や汗をかきながらデバッグしていました。

  4. 意図しないジョブの増殖:

    Schedulable Apexが自分自身を再スケジュールすると、元の CronTrigger とは別に新しい CronTrigger が生成されます。外部APIが復旧した後も、この「リトライによって生成された」ジョブ群が次々と実行され、二重登録やデータの不整合を引き起こす可能性がありました。実際には、Batch Apex側で重複処理をある程度吸収していましたが、精神衛生的には最悪でした。

今ならこうする(別の選択)

この経験を経て、非同期処理の設計思想は大きく変わりました。

  • Schedulable Apexは「トリガー」に徹する:

    Schedulable Apexの役割は、あくまで特定の時間にBatch ApexやQueueable Apexの実行を「トリガーする」こと。それ以上のロジック(特にエラーハンドリングや再試行)は持たせないようにします。

  • 再試行ロジックはBatch/Queueableの内部に閉じる:

    外部API連携における一時的なエラーからの回復は、Batch Apexの execute メソッド内でレコード単位でハンドリングするか、Queueable Apex で処理をチェインさせる形にします。Queueableであれば、System.enqueueJob(new MyQueueableClass(someParam), delaySeconds) のように、ある程度の遅延を持たせて自身を再エンキューすることも可能ですし、再試行回数も制御しやすいです。この方法であれば、ジョブが無限に増殖する心配もありません。

    // 例:Queueableでの再試行
    public class MyApiCallQueueable implements Queueable, Database.AllowsCallouts {
        private Id recordId;
        private Integer retryCount;
    
        public MyApiCallQueueable(Id recordId, Integer retryCount) {
            this.recordId = recordId;
            this.retryCount = retryCount;
        }
    
        public void execute(QueueableContext context) {
            try {
                // 外部API呼び出しロジック
                // ...
                // 成功したら終了
            } catch (Exception e) {
                System.debug('API Call failed for ' + recordId + ': ' + e.getMessage());
                if (retryCount < 3) { // 最大3回までリトライ
                    // 指数バックオフなどの遅延ロジックをここに入れることも可能
                    System.enqueueJob(new MyApiCallQueueable(recordId, retryCount + 1));
                    // 今ならこう書く。QueueableのチェインはFlex Queueを消費するものの、
                    // Schedulableを無限に生成するよりは管理しやすい。
                    // 失敗回数制限と適切な遅延を持たせることで、システムへの負荷を最小限にする。
                } else {
                    // 最大リトライ回数を超えたらエラーログを記録し、手動介入を促す
                    System.debug('Max retries reached for ' + recordId);
                    // エラーオブジェクトを登録するなど、明示的な失敗処理を行う
                }
            }
        }
    }
            
  • バックオフ戦略の導入:

    再試行を行う場合、すぐに再試行するのではなく、指数関数的に待機時間を長くするバックオフ戦略を導入します。これにより、外部システムが回復するまでの時間を稼ぎ、過度なリクエストで負荷をかけないようにします。

  • 明示的なエラーログと通知:

    失敗したレコードや処理はカスタムオブジェクトにエラーログとして記録し、管理者に通知する仕組みを導入します。自動再試行が最終的に失敗した場合、または特定の回数リトライしても成功しない場合は、必ず人間にアラートを上げるようにします。


これは、Salesforce開発者として私が初めてScheduler周りで「やらかした」時の、自身の判断とその後悔の記録です。あの時はまさか、自分の書いたコードがシステム全体の非同期処理を麻痺させるとは思いもしませんでした。非同期処理、特にスケジュール系の処理は、常に「もし無限ループに陥ったらどうなるか?」という最悪のシナリオを想定して設計すべきだと痛感しています。

これは当時の自分向けのメモだ。

コメント