Salesforce SOAP API での更新処理、部分成功を見落とした痛恨の一撃

A. soap api で実際にやらかした判断

まだSalesforce開発に本格的に携わり始めたばかりの頃、外部システムとの連携でSOAP APIの`update`コールを使って大量データを更新するバッチ処理を組むことになりました。数千件のレコードを外部システムからSalesforceに毎日連携する、というシンプルな要件です。

私は当時、JavaのAxis2を使ったクライアントを構築していました。数百件単位でデータをチャンクに分け、1リクエストあたり最大200件(SalesforceのGovernor Limitを意識して)のSObjectリストをSOAP APIの`update()`メソッドに渡していました。ここまでは良かったんです。

当時の私の判断はこうでした。SOAP APIの`update()`メソッドは`SaveResult[]`を返します。この`SaveResult`配列をループし、それぞれの`saveResult.isSuccess()`が`true`なら成功、`false`なら失敗。という認識でした。非常にシンプルで、初歩的なエラーハンドリングだと思っていました。

// 当時の不十分なエラーハンドリング(イメージ)
// List<SObject> recordsToUpdate; // 200件以下のリスト
// SforceService binding; // 初期化済み

SaveResult[] results = binding.update(recordsToUpdate.toArray(new SObject[0]));

for (int i = 0; i < results.length; i++) {
    SaveResult sr = results[i];
    if (!sr.isSuccess()) {
        // ここでログ出力したり、連携元にエラーを通知したり...
        // しかし、実際にはこのチャンク全体の更新が失敗したと認識していた
        System.err.println("レコード更新に失敗しました。詳細不明:" + sr.getId()); // IDは出力してたけど、それがどのレコードかは意識が薄かった
        // ... このチャンクの全レコードを失敗扱い ...
    } else {
        // ... 成功扱い ...
    }
}

これが、後々大きな問題を引き起こしました。ある日、連携元システムから「データが正しく更新されていない」という問い合わせが来ました。私のバッチ処理のログには、`isSuccess()`が`false`になったというエラーが記録されており、私はそのチャンク内の200件全てが失敗したと認識していました。なので、連携元には「この時間のデータは全件失敗しました」と伝えていました。

しかし、Salesforce側を確認すると、そのチャンク内の200件のうち、例えば10件だけがエラーで、残りの190件は正しく更新されていたのです。当時の私は「部分成功」という概念をSOAP APIの`update`の戻り値で真剣に考えていませんでした。

`SaveResult`オブジェクトには、`isSuccess()`以外に`getErrors()`というメソッドがあります。これが肝心でした。`isSuccess()`が`false`の場合でも、それはあくまでその個別のSObjectに対するDML操作が失敗したことを示しているだけで、同じリクエスト内の他のSObjectについては成功している可能性があるのです。つまり、1回の`update`コールで渡したSObjectリストのうち、一部だけがエラーになり、残りは成功する「部分成功」という状態が頻繁に発生します。

私の当時のコードでは、`isSuccess()`が`false`になった時点で「このチャンク全部ダメだ」と判断し、エラーログだけを出して次のチャンクに進んでいました。連携元システムも、私が「全件失敗」と伝えたため、当該時間のデータを再度送り直したり、手動でリカバリしたりする手間が発生しました。実際には190件は成功しているので、二重更新のリスクもはらんでいました。

この時は、何が成功して何が失敗したのかを正確に把握し、連携元システムに伝えることの重要性を痛感しました。

今ならこうする:`getErrors()`の活用

もし今、同じような状況に直面したら、`SaveResult`の処理はもっと細かく行います。特に`getErrors()`の中身をしっかりと見て、どのレコードがどんな理由で失敗したのかを明確にします。

// 今ならこう書く(イメージ)
// List<SObject> recordsToUpdate; // 200件以下のリスト
// SforceService binding; // 初期化済み

Map<String, SObject> idToOriginalRecordMap = new HashMap<>();
for(SObject record : recordsToUpdate) {
    idToOriginalRecordMap.put(record.getId(), record); // 元のレコードとIDを紐付けておく(新規作成ならIDはnullなので注意)
}

SaveResult[] results = binding.update(recordsToUpdate.toArray(new SObject[0]));

List<String> successfulIds = new ArrayList<>();
List<Map<String, String>> failedRecordsDetails = new ArrayList<>();

for (int i = 0; i < results.length; i++) {
    SaveResult sr = results[i];
    SObject originalRecord = recordsToUpdate.get(i); // i番目のレコードが対応

    if (sr.isSuccess()) {
        successfulIds.add(sr.getId());
    } else {
        StringBuilder errorMessages = new StringBuilder();
        for (com.sforce.soap.partner.Error error : sr.getErrors()) {
            errorMessages.append(" - ").append(error.getStatusCode()).append(": ").append(error.getMessage());
        }
        Map<String, String> errorDetail = new HashMap<>();
        errorDetail.put("OriginalRecordId", (originalRecord.getId() != null) ? originalRecord.getId() : "N/A (New Record)");
        errorDetail.put("SalesforceResultId", sr.getId()); // 成功した場合はIDが入るが、失敗時でも一部入ることがある
        errorDetail.put("ErrorMessages", errorMessages.toString());
        failedRecordsDetails.add(errorDetail);
    }
}

// 処理後:成功したIDリストと、失敗したレコードの詳細リストを連携元に通知する
// ... logging and notification logic ...

この修正によって、どのレコードが成功し、どのレコードがどんな理由で失敗したのかが明確になります。連携元システムも、成功したレコードは再送不要、失敗したレコードのみ再送やデータ修正を行う、といった適切なリカバリ処理を組むことができるようになります。

当時この問題に気づくまで、デバッグと連携元との調整に丸1日以上を費やしました。今では笑い話ですが、あの時の「なぜだ!?」という焦りは今でも覚えています。

SOAP APIに限らず、SalesforceのDML操作の戻り値は、常に部分成功を意識して詳細まで確認するべきだと、この経験から学びました。


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

コメント