GAS『Exceeded Maximum Execution Time』の原因と対処法

GAS『Exceeded Maximum Execution Time』の原因と対処法

Google Apps Scriptの実行が許可時間を超えると発生する。主因は「大量データの逐次処理」「SpreadsheetやDriveへの過剰なAPI呼び出し」「外部APIの待ち時間」「同時実行による競合」など。処理の分割・バッチ化・再実行設計・計測とチューニングで回避できる。

エラーの意味と発生条件

・1回の実行時間が制限を超えた(実行形態やトリガー種別で上限は異なる)
・onEdit/onOpen/カスタム関数など短時間上限の実行で重い処理を走らせた
・スプレッドシートを1セルずつ読み書きして処理時間を浪費
・UrlFetchAppで多数の外部APIを直列で呼び出し待ち時間が積み上がった
・重いループ/再計算/正規表現でCPU時間を使い切った

まずやること:時間の可視化(どこで詰まっているか)

// 区間ごとの所要時間をログに出す(console.time系はV8で利用可)
function timed(fn, label) {
  const t0 = Date.now(); const r = fn(); const ms = Date.now() - t0;
  console.log(`⏱ ${label}: ${ms}ms`); return r;
}

function example() {
  timed(() => SpreadsheetApp.openById('...'), 'openById');
  // 以降、重い区間にtimedを挟む
}

・どの区間が支配的かを特定して、そこから対処する。

最短復旧:処理を分割して複数回に分ける(チャンク & 継続)

// 1回で全部やらず、n件ずつ処理。再開位置(cursor)はPropertiesに保存。
function processInChunks() {
  const props = PropertiesService.getScriptProperties();
  const cursor = Number(props.getProperty('cursor') || 2); // 2行目から
  const CHUNK_ROWS = 1000;
  const DEADLINE = Date.now() + 60_000;  // 余裕をもって1分で切り上げ

  const sh = SpreadsheetApp.openById('YOUR_SHEET_ID').getSheetByName('Data');
  const last = sh.getLastRow();
  const end = Math.min(cursor + CHUNK_ROWS - 1, last);
  if (end < cursor) return;

  const values = sh.getRange(cursor, 1, end - cursor + 1, sh.getLastColumn()).getValues();
  // ... valuesを処理 ...

  // 継続判定:締め切りが近い/まだ残りがある → 次回へ
  if (end < last || Date.now() > DEADLINE) {
    props.setProperty('cursor', String(end + 1));
    scheduleNext(1); // 1分後に続き
  } else {
    props.deleteProperty('cursor'); // 完了
  }
}

function scheduleNext(minutes) {
  ScriptApp.newTrigger('processInChunks')
    .timeBased().after(minutes * 60_000).create();
}

・「締め切り前に自発的に中断→続きは次回」の型に切り替える。

スプレッドシート操作の高速化(getValues/setValuesの一括)

// NG:1セルずつアクセス(遅い/上限にかかりやすい)
for (let r = 0; r < rows; r++) {
  for (let c = 0; c < cols; c++) {
    sh.getRange(start + r, 1 + c).setValue(data[r][c]);
  }
}

// OK:矩形で一括読み書き
const range = sh.getRange(start, 1, data.length, data[0].length);
range.setValues(data);

// 読みも一括してメモリ上で処理してから一括で書く
const values = sh.getDataRange().getValues();
// ... map/filter/reduce で加工 ...
sh.getRange(1, 1, values.length, values[0].length).setValues(values);

・「往復回数を減らす」ことが最も効く。可能なら列/行で塊にする。

外部APIの待ち時間短縮(fetchAll・バックオフ・タイムアウト)

// 複数リクエストはfetchAllで同時に投げる(外部の応答待ち時間を短縮)
function fetchParallel(urls) {
  const reqs = urls.map(u => ({url: u, muteHttpExceptions: true, followRedirects: true}));
  const res = UrlFetchApp.fetchAll(reqs);
  return res.map(r => ({code: r.getResponseCode(), body: r.getContentText()}));
}

// リトライ(指数バックオフ)
function withRetry(fn, tries = 5) {
  let wait = 400;
  for (let i = 0; i < tries; i++) {
    try { return fn(); } catch (e) {
      if (i === tries - 1 || !/timeout|rate|quota|internal/i.test(e.message)) throw e;
      Utilities.sleep(wait); wait = Math.min(wait * 2, 8000);
    }
  }
}

・直列fetchの積み上がりを避け、失敗は短い待ち→再試行で復帰させる。

時間ガード:実行時間の上限前に安全停止→続きは自動起動

// 実行開始時刻を記録し、ループ途中で締切を監視
function guardedLoop() {
  const started = Date.now();
  const LIMIT = 5 * 60_000;  // 想定上限より手前に設定
  for (let i = 0; i < 999999; i++) {
    // ... 処理 ...
    if (Date.now() - started > LIMIT) {
      PropertiesService.getScriptProperties().setProperty('resumeIndex', String(i));
      scheduleNext(1);
      return; // ここで終了(次回続き)
    }
  }
}

・「自分で止まる→次へ」を入れると、Exceededを回避しつつ完走できる。

トリガー設計:短いエントリでは重い処理を走らせない

/*
・onEdit/onOpen/カスタム関数は短時間上限。重い処理は:
  1) 軽い検証だけ行ってキュー(Properties/シート)に積む
  2) 時間主導トリガー(毎分/5分)やボタンクリックでキューをバッチ処理
*/
function enqueueJob(payload) {
  const cache = CacheService.getScriptCache();
  cache.put('job', JSON.stringify(payload), 60); // 60秒キャッシュなど
}

・「即時処理」から「バッチ処理」に発想を切り替える。

同時実行の競合を避ける(LockService)

function job() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30_000)) return; // 先行ジョブに譲る
  try {
    // 排他が必要なシート/ファイル更新
  } finally { lock.releaseLock(); }
}

・重複実行で処理時間が伸びる/エラーが出るのを防ぐ。

アルゴリズムを見直す(探索はMap/Set、まとめてJOINする)

// NG:都度find(O(n^2)になりがち)
const out = [];
for (const row of left) {
  const hit = right.find(r => r[0] === row[0]);
  out.push([row[0], hit ? hit[1] : '']);
}

// OK:事前にMapで索引を作る(O(n))
const idx = new Map(right.map(r => [r[0], r[1]]));
const out2 = left.map(row => [row[0], idx.get(row[0]) ?? '']);

・計算量の改善は「時間制限」対策として効果が大きい。

Advanced Sheets APIで一括更新(batchUpdate/values.batchUpdate)

// サービス(リソース)を有効化してから利用
function batchClearAndWrite(sheetId) {
  const requests = [{ updateCells: { range: { sheetId: 0 }, fields: 'userEnteredValue' } }];
  Sheets.Spreadsheets.batchUpdate({requests}, sheetId);

  const body = {
    valueInputOption: 'USER_ENTERED',
    data: [{ range: 'Data!A1', values: [['A','B'],['C','D']]}]
  };
  Sheets.Spreadsheets.Values.batchUpdate(body, sheetId);
}

・SpreadsheetAppの多回呼び出しをまとめて短時間で終わらせる。

キャッシュ/保存で再計算を避ける(CacheService/Properties/Drive)

// 高価な取得結果をキャッシュ
function getUsers() {
  const cache = CacheService.getScriptCache();
  const key = 'users';
  let json = cache.get(key);
  if (!json) {
    const users = AdminDirectory.Users.list({domain: 'example.com'}).users;
    json = JSON.stringify(users);
    cache.put(key, json, 300); // 5分キャッシュ
  }
  return JSON.parse(json);
}

・同じ計算/取得を繰り返さない。

よくあるNG→OK(クイック修正)

× 1セルずつ setValue          →  ○ setValues で一括
× DataRange を何度も取得       →  ○ 1回だけ取得して配列で処理
× 直列UrlFetch を大量実行      →  ○ fetchAll で同時実行 + バックオフ
× onEdit で重処理               →  ○ キューに積んで時間トリガーで処理
× 最後まで走り切ろうとする     →  ○ 締切前に停止し、次回へ継続

再現→解消の通し例(10万行の加工を安全に完走)

// 1) 10万行をチャンク処理+継続
function runLargeJob() {
  const props = PropertiesService.getScriptProperties();
  const row = Number(props.getProperty('row') || 2);
  const CHUNK = 2000, deadline = Date.now() + 60_000;

  const ss = SpreadsheetApp.openById('YOUR_SHEET_ID');
  const sh = ss.getSheetByName('Data');
  const last = sh.getLastRow();
  const end = Math.min(row + CHUNK - 1, last);

  if (end >= row) {
    const values = sh.getRange(row, 1, end - row + 1, sh.getLastColumn()).getValues();
    // 加工例:2列目を大文字化
    for (let i = 0; i < values.length; i++) values[i][1] = String(values[i][1] || '').toUpperCase();
    sh.getRange(row, 1, values.length, values[0].length).setValues(values);
  }

  if (end < last && Date.now() < deadline) {
    props.setProperty('row', String(end + 1));
    runLargeJob(); // 余裕があれば再帰で次チャンク
  } else if (end < last) {
    props.setProperty('row', String(end + 1));
    scheduleNext(1); // 時間切れ → 次回
  } else {
    props.deleteProperty('row'); // 完了
  }
}

チェックリスト(上から順に潰す)

□ 重い区間を計測し、ボトルネックを特定したか
□ Spreadsheet操作は一括(getValues/setValues)に置き換えたか
□ 外部APIはfetchAll/バックオフで待ち時間を圧縮したか
□ 処理をチャンク化し、締切前に自発停止→次回継続できるか
□ 短時間上限のトリガーに重処理を載せていないか
□ LockServiceで競合を防いでいるか
□ アルゴリズム(探索/結合/再計算)を見直したか
□ Advanced Sheets APIの一括更新を活用したか