GAS の「6分間の実行制限(タイムアウト)」を回避する方法【コードコピペ可!】

Google Apps Script (GAS) で重い処理(大量のメール送信、スクレイピング、API連携など)を実行したとき、誰もが一度はぶつかる壁。

Exceeded maximum execution time(実行時間が制限を超えました)」

GASには「1回の実行は6分まで」という厳格なルールがあります。 これを回避するには、手動で都度再実行する必要があるのですが、正直めんどくさいですよね?

そこで今回は、「今あるコードのロジックは変えずに、たった4箇所追記するだけ」 で、自動的に続きから再開してくれる魔法のクラス(部品)を作りました。

【サクッと要約!】
・数千件のループ処理も、放置しておくだけで最後まで終わります。
・4分半経過すると自動で一時停止し、1分後に続きから再開します。
・難しい「トリガー設定」や「進捗保存」の処理を書く必要はありません。

目次

ロジックの解説

ではロジックを解説します。

GASには同じ処理を連続して6分間以上続けて処理できない(タイムアウトする)という制約があります。
そのため処理を行う前にストップウィッチを走らせ、今が何秒経過したかをカウントしておき、それが360秒(=6分間の壁)に到達する前に、意図的に処理を中断させる。そして、中断させる際に、数分後、処理を再開させるトリガーを自動で設置する。というロジックを組むことによって、理論上6分の壁を超えることが可能なのです。

※ここでは、余裕を持った処理を実行するため、4分40秒経過したら中断するようにします。

時間の監視: 開始から「4分40秒」経過したかを常にチェックします。
進捗の保存: 時間が来たら、現在のループ番号(i)を「スクリプトプロパティ」という見えない保存場所にメモします。
未来の予約: 「1分後にまた私(関数)を実行して!」という時限トリガーをセットします。
再開: 次回実行時、プロパティを見て「ああ、前回は500番までやったな」と思い出し、501番からループを回します。

では、以下がコピペで使えるコードになります。

コピペ用コード

function myFunction() {
  const looper = new ScriptLooper("myFunction");               // ★追加1 function名を一緒にする
  const data = getData();                                      // ★追加2 単なるコピペ

  for (let i = looper.getStartIndex(); i < data.length; i++) {  // ★追加3 単なるコピペ
    if (looper.shouldYield(i)) {                     // ★追加3 単なるコピペ
      return;                                          // ★追加3 単なるコピペ
    }                                                   // ★追加3 単なるコピペ


 // ===============================================
  // ... ここに処理を完遂させたい内容をかく ... 
  // ... ここに処理を完遂させたい内容をかく ... 
  // ... ここに処理を完遂させたい内容をかく ... 
  // ===============================================

  }
  looper.finish();                                // ★追加4 単なるコピペ
}




// ===============================================
// ここ以降のコードは何も変更する必要はありません
// ===============================================
class ScriptLooper {
  constructor(functionName, timeLimitSec = 280) {
    this.funcName = functionName;
    this.limit = timeLimitSec * 1000;
    this.startTime = new Date().getTime();
    this.props = PropertiesService.getScriptProperties();
    this.key = 'LOOP_INDEX_' + functionName;
    
    // 開始時に古いトリガーを削除しておく
    this._deleteTrigger();
  }

  // 続きから再開するための開始番号を取得
  getStartIndex() {
    return parseInt(this.props.getProperty(this.key)) || 0;
  }

  // 制限時間が迫っているかチェック (trueなら中断すべき)
  shouldYield(currentIndex) {
    const elapsed = new Date().getTime() - this.startTime;
    if (elapsed > this.limit) {
      // 現在のインデックスを保存
      this.props.setProperty(this.key, currentIndex.toString());
      // 1分後に自分自身をトリガー予約
      ScriptApp.newTrigger(this.funcName).timeBased().after(60 * 1000).create();
      console.log(`時間切れ回避: ${currentIndex}番目で中断し、次回予約しました。`);
      return true; // 中断指示
    }
    return false; // 続行OK
  }

  // 処理完了後の後始末
  finish() {
    this.props.deleteProperty(this.key);
    console.log("全処理完了。設定をリセットしました。");
  }

  _deleteTrigger() {
    const triggers = ScriptApp.getProjectTriggers();
    triggers.forEach(t => {
      if (t.getHandlerFunction() === this.funcName) ScriptApp.deleteTrigger(t);
    });
  }
}

具体的なコード例

今回は、6分を超える処理のとして「スプレッドシートの顧客リスト(1000件以上)に、1件ずつメールを一斉送信する。」という内容の処理を行うコードを用意しました。

【Before】修正前:必ずタイムアウトしてしまうコード

これは一般的な書き方ですが、データ量が多いと途中で「Exceeded maximum execution time」エラーが出て止まってしまいます。メール送信API(MailApp)の使用と、サーバー負荷軽減のための待機時間(Utilities.sleep)があるため、100件も処理しないうちに6分制限に引っかかってしまいます。

function sendMailBatch() {
  // 1. シートとデータを取得
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("配信リスト");
  const data = sheet.getDataRange().getValues(); // 全データ取得

  // 2. ループ処理 (1行目はヘッダーなので、i=1 からスタート)
  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    const name = row[0];  // A列: 名前
    const email = row[1]; // B列: メールアドレス
    const status = row[2]; // C列: ステータス

    // すでに送信済みならスキップ
    if (status === "送信済み") {
      continue;
    }

    try {
      // 3. 重い処理 (メール送信)
      MailApp.sendEmail({
        to: email,
        subject: "重要なお知らせ",
        body: `${name} 様\n\nいつもご利用ありがとうございます。\n...`
      });

      // 4. 完了記録
      // 行番号は i+1 になることに注意
      sheet.getRange(i + 1, 3).setValue("送信済み");
      console.log(`${name}様にメール送信完了`);

      // 5. 待機 (これがあるため時間がかかる)
      Utilities.sleep(2000); // 2秒待機

    } catch (e) {
      console.error(`送信エラー: ${email}`);
      sheet.getRange(i + 1, 3).setValue("エラー");
    }
  }
}

この処理を6分超えても実行されるようにするには、以下のように変更すれば良いのです!

【After】修正後:6分の壁を越えて完走するコード

修正前のコードに、「★」マークのついた4箇所を書き換え(追加)し、 一番下に「ScriptLooperクラス」を貼り付けます。

function sendMailBatch() {
  const looper = new ScriptLooper("sendMailBatch");   // ★追加1 function名を一緒にする
  const startIndex = looper.getStartIndex() || 1;    // ★追加2 単なるコピペ


  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("配信リスト");
  const data = sheet.getDataRange().getValues();


  for (let i = startIndex; i < data.length; i++) {
    if (looper.shouldYield(i)) {              // ★追加3 単なるコピペ
      return;                                 // ★追加3 単なるコピペ
    }                                         // ★追加3 単なるコピペ

  // ===============================================
  // ここから下は元のコードと全く同じです
  // ===============================================
    const row = data[i];
    const name = row[0];
    const email = row[1];
    const status = row[2];

    if (status === "送信済み") continue;

    try {
      MailApp.sendEmail({
        to: email,
        subject: "重要なお知らせ",
        body: `${name} 様\n\nいつもご利用ありがとうございます。\n...`
      });

      sheet.getRange(i + 1, 3).setValue("送信済み");
      console.log(`${name}様にメール送信完了`);
      
      Utilities.sleep(2000); 

    } catch (e) {
      console.error(`送信エラー: ${email}`);
      sheet.getRange(i + 1, 3).setValue("エラー");
    }
  // ===============================================
  // ここまで
  // ===============================================

  }
  looper.finish();                        // ★追加4 単なるコピペ
}




// ===============================================
// ここ以降のコードは何も変更する必要はありません
// ===============================================
class ScriptLooper {
  /**
   * @param {string} functionName - 実行中の関数名(トリガー再設定用)
   * @param {number} timeLimitSec - 制限時間(秒)。デフォルト280秒(4分40秒)
   */
  constructor(functionName, timeLimitSec = 280) {
    this.funcName = functionName;
    this.limit = timeLimitSec * 1000;
    this.startTime = new Date().getTime();
    this.props = PropertiesService.getScriptProperties();
    this.key = 'LOOP_INDEX_' + functionName;
    
    // 起動時に、自身の古いトリガーがあれば削除しておく(重複防止)
    this._deleteTrigger();
  }

  /**
   * 前回の続きから再開するためのインデックスを取得
   * @return {number} 保存されたインデックス(なければ0)
   */
  getStartIndex() {
    return parseInt(this.props.getProperty(this.key)) || 0;
  }

  /**
   * 制限時間が迫っているか判定し、次回予約を行う
   * @param {number} currentIndex - 現在処理中のインデックス
   * @return {boolean} trueなら中断すべき
   */
  shouldYield(currentIndex) {
    const elapsed = new Date().getTime() - this.startTime;
    
    // 制限時間を超えていたら
    if (elapsed > this.limit) {
      // 1. 現在の場所を保存
      this.props.setProperty(this.key, currentIndex.toString());
      
      // 2. 1分後に再開するトリガーをセット
      ScriptApp.newTrigger(this.funcName)
               .timeBased()
               .after(60 * 1000)
               .create();
      
      console.log(`時間切れ回避: ${currentIndex}番目で中断し、次回(1分後)を予約しました。`);
      return true; // 中断指示
    }
    return false; // 続行OK
  }

  /**
   * 全処理完了時の後始末
   */
  finish() {
    // 保存していた進捗を削除
    this.props.deleteProperty(this.key);
    console.log("全処理が完了しました。設定をリセットしました。");
  }

  // 内部用:トリガー削除
  _deleteTrigger() {
    const triggers = ScriptApp.getProjectTriggers();
    triggers.forEach(t => {
      if (t.getHandlerFunction() === this.funcName) {
        ScriptApp.deleteTrigger(t);
      }
    });
  }
}

これでコーディングは完了です。

目次