【Salesforce】Trigger実装に潜むガバナ制約エラー
はじめに
クロス・マーケティングループでは一部グループ会社において、Salesforceを利用していますが、実態としてはほぼPaaSのような使い方をしています。
営業活動よりは売上管理に寄せた機能が作られており、売上管理はできているけど営業活動にはまだ物足りない状況で、その結果Salesforceの外にCRM機能が作られたり、、と、Salesforceの良さを活かせているかどうかは正直よくわかっていません。
利用している機能の80%くらいを作りこんでいるため、カスタムオブジェクト、Trigger、Apex‥と大量のソースが存在しますが、その中には使っているか不明なソースも存在し、更にリリースのためだけのテストクラスなど、エンジニア目線ではとても保守性が低く、改善点しかなくどこから手を付けるか、と頭を抱えるシステムになっています。
それでも一定使い慣れていたり、業務の流れは確立できていたりと、現場からは何かを大きく改善したいだとか、使いにくいなど不満の声は上がってこず(こちらに届かないだけかもしれませんが)、またPOも存在しないためこの先の行く末の検討すら行われず。。ただただ、現状を維持するための、手動のデータ更新、経年劣化による不具合(建物?)のとりあえずの延命処置改修、再現ができない問い合わせをなんとかかんとか対応する日々です。
さて、そんな中で、データ連携システムを介して、会計システムとSalesforceのデータ連携を行っているチームから「データを一括更新仕様としたとき、200件以上更新ができる時とできない時があり作業が思うように進められない為、回避策を教えてほしい」というという問い合わせを受けました。
ガバナ制限による影響を予想しましたが、そもそもガバナ制限について、正直曖昧な知識しかなかった為、具体的な回避方法については不明でした。
そのため、改めてSalesforceのREST APIを使用したデータ更新時のエラーとガバナ制約について調査を行い解決ができたので、備忘も兼ねて整理していきたいと思います。
結論
先に結論から。
■エラー原因
トランザクション内で100回以上のクエリを発行したことによるガバナ制限のエラー
■解決策
batchSizeを指定することでクエリ発行回数を100回以下にした
ここに至るまでの流れと苦労がお伝えできればと思います。
エラー原因について
調査の結果、エラーの原因が3つあることを特定しました。
①入力規則
古いデータが新しい入力規則に適合せず、更新しようとしたときにエラー
②トランザクション単位の同期Apexの制限
更新対象オブジェクトの処理内で100回以上のselectを行っていたことが原因でエラー
Apexガバナ制限
③APIコールの制限
200件以上のレコードをupdateしようとしてエラー
SOAP API コールの制限
①については、対象レコードの処理がスキップされるだけのため放置して問題はありませんでした。また、古いデータも不要とのことでしたので特に対応なしとしました。
問題は②と③で、この2つはエラーが発生すると全件ロールバックされる仕様であるため、回避策を検討する必要がありました。
※②を解決することで③も回避できたので、以降は②についての話になります。
システム構成とデータフローについて
今回のシステム構成は以下の通りです。
Salesforce | データ連携システム | 会計システム
※データ連携システムがAPIリクエストしてくる
データフローは、会計システムのデータをデータ連携システムが取得し、その後データ連携システムを介してSalesforceのデータを更新する形となっています。
この時、更新対象のオブジェクトにはTriggerが実装されており、updete処理でTriggerが動作するロジックとなっていました。
なので、このTriggerを更に調べて、回避方法を検討します。
回避策について
このTriggerですが、ループ処理内でクエリを投げていました。そんな実装を見るに、そもそも1レコードしか更新を行わない前提で作られたのだろうと容易に想像ができました。(そういう実装が結構あるので‥)
ループ内の処理も地味に600行くらいあり、、ロジック修正はあきらめ、更新できる件数を特定する方向で考えました。
更新処理を走らせながらログを取得し、ログの中の「SOQL_EXECUTE_BEGIN」からクエリ発行の回数を確認したところ、1レコードにつき、Trigger内で6回のSelectが行われていることがわかりました。
※ちなみにこんな感じです。
trigger ObjectA_trigger on ObjectA__c (after delete, after insert, after undelete, after update, before delete, before update) {
if(Trigger.isBefore){
if(Trigger.isUpdate || Trigger.isInsert){
for(ObjectA__c a : Trigger.New){
// if条件なしに6回のSelect
Select A・・・
Select B・・・
Select C・・・
Select D・・・
Select E・・・
Select F・・・
// 更に別オブジェクトも更新しに行く(Triggerあり)
update ObjectB__c
}
}
}
}
②のエラーを回避するためには、1回の処理で最大100回のSelectに収める必要があり、以下の計算ができます。
100 / 6 = 16.66 ≒ 16
つまり、1回の処理で最大16件までの更新が可能であることがわかります。
これにより、リクエストを投げる際にBatchSizeに16を指定することで問題を回避できることがわかりました。
実際にBatchSizeに16を指定して更新が成功し、更に、BatchSizeを16にしたため、③の200件以上更新のエラーについては考慮する必要はなくなりました。
(なお、本来であればリクエストが200件以上でもSalesforceが200件に区切ってくれるのでエラーになることはないのですが、この件は割愛します。)
余談
この回避策がダメだった場合(例えば16件ずつの更新だと時間がかかるとか、データ連携システムがBatchSizeが指定できないとか‥)この処理用の専用プロファイルを作成し、該当の処理を回避する処理を加えようと考えていました。
特定の機能のための特別な回避策は、コードの可読性を下げる上、今後、連携システムを使わなくなった時のために管理し続けるなど、いらない保守も付きまとう為、あまりやりたくない方法だったので、16件ずつ更新という方法でも満足してもらえて安堵しています‥。
※乱暴な修正イメージ
trigger ObjectA_trigger on ObjectA__c (after delete, after insert, after undelete, after update, before delete, before update) {
// start 今回の一括更新で以下の処理は行わなくていいので専用のプロファイルを使ってスキップさせる
if(UserInfo.getProfileId() != 'XXXXXXXXXXXXXXX'){
return;
}
// end
if(Trigger.isBefore){
if(Trigger.isUpdate || Trigger.isInsert){
for(ObjectA__c a : Trigger.New){
// 6回Select処理
}
}
}
}
Triggerの実装には気をつけよう
今回のTriggerは元々「1レコードしかTriggerを通らない」という前提で実装されている可能性があり、一括更新に耐えられない作りになっていました。
Triggerはリストでデータが渡ってくるため、意識して回避するロジックを作っておかないと「16件ずつ更新する」ということになりかねません。
Trigger内でループ処理を使って、さらにその中でクエリを投げていないか、まずはここから意識してガバナ制限を回避しましょう。
追記
ここまで書いた後に「一括更新が考慮されたTrigger」にActionクラスから1レコードずつ処理をさせて、ガバナ制限が発生するという事象が発生するなどしていました。。
トランザクションを意識した実装を心がけましょう!!!
トランザクションの境界は、トリガ、クラスメソッド、匿名のコードブロック、Visualforce ページ、カスタム Web サービスメソッドのいずれかにすることができます。
※参考まで‥
public with sharing class ObjectC_Action {
private void doUpdate(ObjectC_Controller ctrl) {
for(ObjectC__c objectC : ctrl.updateList) {
// ここで ObjectC のTriggerが呼び出される
// 呼び出し元がfor文内だったため、トランザクションのガバナ制限でエラーになりました。
update objectC;
}
}
}
trigger ObjectC_Trigger on ObjectC__c (before insert, before update) {
Map<Id, ObjectC__c> oldMap = Trigger.oldMap;
Set<Id> Ids = new Set<Id>();
// 検索条件用のIdリストを作成
for(ObjectC__c newRecord : Trigger.new){
// 対象を絞り込み、検索用のIdリストを生成
if(oldMap.get(newRecord.Id).CustomField__c != newRecord.CustomField__c){
Ids.add(newRecord.RelationObjectD__c);
}
}
// 検索条件をリストすることで、1回のクエリ発行で済みます。
// Triggerだけ見れば、ガバナ制限の考慮はできていました。
List<ObjectD__c> objectDList = [Select Id, Name From ObjectD__c Where id in :Ids];
・
・
・
}