私はSalesforce開発者として、これまで幾度となく非同期処理の実装に関わってきました。その中でも、特に「queueable apex」に関しては、一度大きな判断ミスをやらかした経験があります。当時は「非同期処理=queueableで何でもいける!」という謎の自信がありました。
プロジェクトの初期フェーズ、特定のオブジェクトの更新トリガから重めの計算処理と関連レコードの更新を走らせる必要がありました。同期処理では明らかにCPU Time Limitに引っかかるリスクが高かったため、非同期化は必須。Future Methodも検討しましたが、引数にSObjectを取れない制約があり、トリガコンテキストから渡したい情報が多かったため、より柔軟なQueueable Apexを選択しました。
これが最初の過ちではなかった。単発で呼ぶ分には問題なかったんです。
// 当初のシンプルな実装イメージ(トリガハンドラから呼ばれる)
public class MyHeavyCalcQueueable implements Queueable, Database.AllowsCallouts {
private List<Id> recordIds;
public MyHeavyCalcQueueable(List<Id> ids) {
this.recordIds = ids;
}
public void execute(QueueableContext context) {
// 重い計算処理...
// 関連オブジェクトの更新...
// ここまでは問題なかった。
}
}
// トリガハンドラから呼ばれるイメージ
// System.enqueueJob(new MyHeavyCalcQueueable(Trigger.newMap.keySet().asList()));
問題は、このQueueableの中でさらに別のQueueableを呼ぶという、悪魔の誘惑に屈してしまったことです。ビジネス要件が複雑化し、「計算結果に基づいてさらに別の種類のレコードを更新し、その更新結果を元にレポート集計用のオブジェクトも更新したい」といった要望が出てきました。同期的にやるとタイムアウトするし、かといって一つのQueueableに全部詰め込むと、それはそれで一つのトランザクションのCPU Time Limit (10,000ms) が気になる。
そこで私は、「処理を細分化して、それぞれを別のQueueableでチェーンさせれば、Governor Limitを回避しつつ、非同期で順番に処理できる!」と考えました。当時の私は、それぞれのQueueableが独立したトランザクションとして扱われること、そしてそれがGovernor Limitの再適用を意味することを理解していましたが、その副作用を深く考えませんでした。
// やらかした連鎖実装イメージ
public class MyHeavyCalcQueueable implements Queueable, Database.AllowsCallouts {
private List<Id> recordIds;
public MyHeavyCalcQueueable(List<Id> ids) { this.recordIds = ids; }
public void execute(QueueableContext context) {
// ① 重い計算処理
// ...
// 計算結果を元に、次のQueueableを呼ぶ
System.enqueueJob(new MyRelatedRecordUpdateQueueable(recordIds)); // ここで連鎖!
}
}
public class MyRelatedRecordUpdateQueueable implements Queueable {
private List<Id> recordIds;
public MyRelatedRecordUpdateQueueable(List<Id> ids) { this.recordIds = ids; }
public void execute(QueueableContext context) {
// ② 関連レコードの更新
// ...
// さらに、レポート集計用のQueueableを呼ぶ
System.enqueueJob(new MyReportAggregationQueueable(recordIds)); // ここでも連鎖!
}
}
public class MyReportAggregationQueueable implements Queueable {
private List<Id> recordIds;
public MyReportAggregationQueueable(List<Id> ids) { this.recordIds = ids; }
public void execute(QueueableContext context) {
// ③ レポート集計用オブジェクトの更新
// ...
// やっと完結
}
}
これで処理は無事に非同期キューに入り、一つずつ実行されていく...はずでした。しかし、これが地獄の始まりでした。
直面した問題点:
- CPU Time Limitの再発: 各Queueableは確かに新しいトランザクションとして実行されますが、一つのビジネスプロセス全体で見ると、結局CPU Timeを合計で消費していることには変わりありません。特に、処理対象レコード数が多かったり、各ステップでのSOQLやDMLが多発すると、それぞれのQueueableが新しいCPU Time Limit (10,000ms) にぶち当たるようになりました。QueueableA -> QueueableB -> QueueableC のどこかで処理が重い箇所があれば、そこで止まる。しかも、QueueableCが止まっても、QueueableAやBは成功しているので、全体としては半端な状態になります。
- 非同期キューの詰まり (Async Apex Jobsが爆発): 大量のレコードが更新されると、それぞれのトリガから上記のような連鎖Queueableが発行されます。結果として、非同期キューに数千、数万といった単位のJobが登録され、本来もっと早く実行されるべき他のBatch ApexやFuture Method、Platform Eventの処理が軒並み遅延し始めました。キューのMonitor画面は常に赤く染まっていました。
- デバッグの困難さ: どのQueueableがどのデータで失敗したのか、そしてその失敗がビジネスプロセス全体のどのステップで発生したのかを特定するのが非常に困難でした。非同期ジョブのIDを辿っていくのも一苦労で、ログもそれぞれのQueueableに分断されているため、横断的に追うのが大変でした。ビジネス部門からは「この処理、いつ終わるんだ?」「途中で止まってるんじゃないか?」と問い合わせが殺到しました。
- トランザクションの整合性問題: 連鎖しているとはいえ、Queueableはそれぞれが独立したトランザクションです。QueueableAが成功し、QueueableBが成功したが、QueueableCが失敗した場合、QueueableAとBで作成・更新されたデータはコミットされたまま残ります。ビジネスプロセス全体として「アトミックに成功または失敗」を保証することができませんでした。リカバリやリトライロジックも複雑になり、どんどんコードが肥大化していきました。
C. 後から「やらなければよかった」と思った設計
このQueueable連鎖設計は、後から「やらなければよかった」と痛感しました。当時の私は、同期処理のGovernor Limitを回避する「魔法の杖」のようにQueueableを見ていたんです。それぞれの処理が独立したトランザクションになることのメリット(部分的な失敗で全体が巻き戻されない、各ステップでDMLが発行できる)ばかりに目が行き、その裏にあるデメリット(全体の整合性担保の難しさ、キューの飽和)を全く考慮していませんでした。
なぜ当時そう思えなかったのか。それは、単に「非同期処理を小さく分割する」という思考に囚われていたからだと思います。一つのJobで全ての処理を完結させるという意識が希薄でした。Queueableは「同期処理から切り離し、単発で完結する」か、せいぜい「ごく限られた条件で、厳密な順序性が不要な次のQueueableを呼ぶ」用途に留めるべきだった。その連鎖の誘惑に勝てなかったのが、私の最大の失敗です。
Salesforceの公式ドキュメントでは、Queueableの連鎖は許容されていますし、それ自体が悪いわけではありません。しかし、その連鎖が「一つのまとまったビジネスプロセス」を構成する場合、それはQueueableの得意分野ではない、ということを身をもって知りました。
E. 今はもう使わなくなった実装・設定
今はもう、上記のような「複雑なビジネスプロセスを複数のQueueableで連鎖させる」実装は使わなくなりました(あるいは、そうしないように強く意識しています)。もし同じような要件が出てきた場合、真っ先に検討するのはBatch Apexです。Batch Apexであれば、`execute`メソッドがチャンクごとに呼び出されるため、一つの`execute`内でCPU Time Limitを意識しつつ、全体としては大量のレコードを処理できます。また、Batch Apexには`finish`メソッドがあり、全てのチャンクが完了した後にまとめて後処理(例えば、エラーレポートの生成や集計処理)を実行できるため、ビジネスプロセス全体の成功/失敗をより適切にハンドリングできます。
当時、Batch Apexを選ばなかった理由の一つに、「処理開始の粒度」がありました。Batch ApexはSchedule Apexや手動での実行が主であり、レコードが更新された直後に非同期処理を開始したい、という要件にはフィットしないと感じていました。しかし、今思えばそれは表面的な理由で、実際にはBatch Apexを`Database.executeBatch`で呼び出すことは可能ですし、その方が遥かに堅牢でした。
もっと言えば、Queueableで連鎖させた処理が本当に「順番に実行される」ことを期待していたのなら、それは根本的な設計ミスでした。非同期キューは並列処理されることがあり、厳密な実行順序は保証されません(特にキューが混雑している場合)。連鎖したとしても、間に別のJobが割り込む可能性は常にあります。
今なら、同期的なトリガからPlatform Eventを発行し、そのPlatform Eventを購読するApexトリガでBatch Apexをキックする、といった疎結合なアーキテクチャも検討します。これにより、トリガからの処理負荷を最小限にしつつ、柔軟な非同期処理を実現できます。
結局のところ、Queueable Apexは「同期トランザクションから切り離す一発の処理」には非常に強力ですが、複雑なオーケストレーションや大規模なデータ処理には向かない、というのが私の結論です。もし連鎖させる必要があるのなら、それはもはやQueueableの範疇を超え、Batch ApexやPlatform Event、あるいは場合によってはExternal Serviceのような、より高レベルな非同期フレームワークを検討すべきタイミングだと、今は強く思います。
これは当時の自分向けのメモだ。あの時の自分に言ってやりたい。「Queueableは魔法じゃないぞ」と。
コメント
コメントを投稿