業務の隙間を埋める技術メモ。

「それ、作れるか?」より 「それ、作って大丈夫か?」を考えたい。 業務で“ちゃんと使える”かどうかを、 実際に手を動かして確かめたログを残しています。

SuiteScriptのガバナンス超過(SSS_OPERATION_TOTAL_GOVERNANCE_EXCEEDED)の原因と対策3パターン

結論(先に言う)

ガバナンス超過の原因は、ほぼ3つのパターンに集約されます。

どれも「開発環境では動く」「少ないデータでは気づかない」という共通点があります。本番に上げてから初めて発覚する、というのが厄介なところです。


パターン①:ループの中でrecord.loadを呼ぶ

一番よくやります。自分も最初にこれで詰まりました。

「受注一覧を検索して、1件ずつ顧客名を取得して処理する」という要件を素直に実装するとこうなります。

// NG:1件ごとにrecord.loadが走る
search.create({
  type: 'salesorder',
  filters: [['status', 'anyof', 'SalesOrd:B']],
  columns: ['internalid', 'entity']
}).each((result) => {
  const orderId = result.id;

  // これが1回10ユニット消費する
  const rec = record.load({
    type: record.Type.SALES_ORDER,
    id: orderId
  });

  const customerName = rec.getValue({ fieldId: 'entityid' });
  log.debug('顧客名', customerName);

  return true;
});

100件処理するとrecord.loadだけで1,000ユニット消費します。UserEventのガバナンス上限がちょうど1,000ユニットなので、これだけで上限に達します。Scheduledでも1,000件を超えたあたりから危なくなります。

対策:search.lookupFieldsでピンポイント取得する

record.loadは対象レコードの全フィールドをサーバーから取ってきます。1フィールドしか読まないのにレコード全体をロードするのは完全に無駄です。

必要なフィールドだけを取得するsearch.lookupFieldsに切り替えると、消費ユニットが大幅に下がります。

// OK:lookupFieldsで必要なフィールドだけ取得
search.create({
  type: 'salesorder',
  filters: [['status', 'anyof', 'SalesOrd:B']],
  columns: ['internalid', 'entity']
}).each((result) => {
  const orderId = result.id;

  // record.loadの代わりにlookupFieldsを使う(消費ユニットが大幅に少ない)
  const fields = search.lookupFields({
    type: search.Type.SALES_ORDER,
    id: orderId,
    columns: ['entityid']
  });

  const customerName = fields.entityid;
  log.debug('顧客名', customerName);

  return true;
});

さらに言うと、検索のカラムに最初から必要なフィールドを含めておけばlookupFieldsすら不要になります。

// ベスト:検索時点で必要なフィールドを取得しきる
search.create({
  type: 'salesorder',
  filters: [['status', 'anyof', 'SalesOrd:B']],
  columns: ['internalid', 'entity', 'entityid'] // 必要なフィールドをここに全部入れる
}).each((result) => {
  const customerName = result.getValue({ name: 'entityid' });
  log.debug('顧客名', customerName);
  return true;
});

「ループの中でrecord.loadを呼んでいないか」は、コードレビューのときに必ず確認するようにしています。


パターン②:afterSubmitで重い処理をぶん回す

前回の記事でも書いたのですが、afterSubmitで重い処理を書いてしまうパターンです。あのときは「保存が遅くなる」という問題として書きましたが、実はガバナンス超過の原因にもなります。

UserEventのガバナンス上限は1,000ユニットです。afterSubmitでsearch.eachを回して1件ずつrecord.submitFieldsを呼ぶと、あっという間に上限に達します。

// NG:afterSubmitでこれをやるとガバナンスもUXも死ぬ
const afterSubmit = (ctx) => {
  const customerId = ctx.newRecord.getValue({ fieldId: 'entity' });

  // search.create で10ユニット
  // each の中で submitFields が1件10ユニット
  // 100件で1,000ユニット → 上限到達
  search.create({
    type: 'salesorder',
    filters: [['entity', 'anyof', customerId]]
  }).each((result) => {
    record.submitFields({
      type: record.Type.SALES_ORDER,
      id: result.id,
      values: { custbody_processed: false }
    });
    return true;
  });
};

対策:afterSubmitではScheduledを蹴るだけにする

afterSubmitでやることは「Scheduled Scriptを起動するだけ」に絞る設計にします。Scheduledのガバナンス上限は10,000ユニットなので、同じ処理でも余裕が10倍あります。

// OK:afterSubmitはScheduledを起動するだけ
define(['N/task', 'N/log'], (task, log) => {
  const afterSubmit = (ctx) => {
    if (ctx.type === ctx.UserEventType.DELETE) return;

    task.create({
      taskType: task.TaskType.SCHEDULED_SCRIPT,
      scriptId: 'customscript_update_related',
      deploymentId: 'customdeploy_update_related',
      params: { custscript_customer_id: ctx.newRecord.getValue({ fieldId: 'entity' }) }
    }).submit();

    log.debug('Scheduled起動', 'afterSubmit完了');
  };

  return { afterSubmit };
});

afterSubmitのガバナンス消費はtask.createsubmitだけになるので、消費ユニットは数十で収まります。


パターン③:MapReduceのmapフェーズに処理を詰め込む

MapReduceを使えばガバナンス問題が解決する、と思って移行したのに、今度はmapフェーズで詰まるというパターンです。

MapReduceはフェーズごとに独立したガバナンスが設定されています。mapフェーズの1キューあたりのガバナンス上限も無制限ではありません。1件の処理にrecord.loadを複数回呼んだり、外部APIを叩いたりすると、1件ずつの処理でもガバナンスを使い切ることがあります。

// NG:mapの中で重い処理を詰め込む
const map = (ctx) => {
  const result = JSON.parse(ctx.value);
  const orderId = result.id;

  // record.loadで10ユニット
  const order = record.load({ type: record.Type.SALES_ORDER, id: orderId });

  // さらにrecord.loadで10ユニット
  const customerId = order.getValue({ fieldId: 'entity' });
  const customer = record.load({ type: record.Type.CUSTOMER, id: customerId });

  // N/https.getで10ユニット
  const response = https.get({ url: `https://api.example.com/customer/${customerId}` });

  // record.submitFieldsで10ユニット
  record.submitFields({ type: record.Type.SALES_ORDER, id: orderId, values: { ... } });

  ctx.write({ key: customerId, value: orderId });
};

1件の処理で40ユニット以上消費しています。件数が多いと積み重なってフェーズ単体で上限超えが起きます。

対策:mapでは「振り分けだけ」してreduceで処理する

mapフェーズの責務を「データの振り分け(ctx.writeするだけ)」に絞り、実際の更新処理はreduceフェーズに持っていく設計にします。reduceは同じkeyのデータをまとめて処理するフェーズなので、複数件をまとめて1回のAPI呼び出しに集約できます。

// OK:mapは振り分けだけ、処理はreduceで
const map = (ctx) => {
  const result = JSON.parse(ctx.value);

  // mapでは最低限の情報だけwriteする
  ctx.write({
    key: result.values.entity.value, // 顧客IDをキーに
    value: result.id                 // 受注IDをバリューに
  });
};

const reduce = (ctx) => {
  // 同じ顧客の受注IDが配列で来る → まとめて処理
  const orderIds = ctx.values.map(v => JSON.parse(v));

  // 顧客レコードは1回だけload
  const customer = record.load({ type: record.Type.CUSTOMER, id: ctx.key });

  // 受注はsubmitFieldsでまとめて更新
  orderIds.forEach(orderId => {
    record.submitFields({
      type: record.Type.SALES_ORDER,
      id: orderId,
      values: { custbody_processed: true }
    });
  });

  ctx.write({ key: ctx.key, value: orderIds.length });
};

mapでrecord.loadを呼ぶのをやめるだけで、フェーズあたりのガバナンス消費が大幅に下がります。


3つのパターンに共通する根本原因

改めて整理すると、ガバナンス爆死の根本原因は「ループの中でコストの高い操作を呼んでいる」という1点に尽きます。

具体的にはこの3操作が高コストです。

  • record.load:レコード全体をDBから取得する
  • record.save / record.submitFields:DBに書き込む
  • N/https.get / N/https.post:外部APIを呼ぶ

これらをループの外に出す、呼び出し回数を減らす、というのがガバナンス節約の基本方針です。次のセクションでは具体的な節約テクニックをまとめます。


文字数は約2,400字、コードブロックは7つです。

パターン②の内部リンクURLは仮のままにしています。SuiteScript完全ガイド記事の実際のURLに差し替えてください。3つのパターンが「開発環境では動く → 本番で詰まる」という同じ構造で書けているので、読者が「自分もこれやってた」と感じやすい内容になっていると思います。